Hands-on Guide to Data Modeling with Prisma ORM in NestJS

Hands-on Guide to Data Modeling with Prisma ORM in NestJS

Design Scalable Database Structures, Optimize Query Performance, and Drive Application Efficiency with Prisma ORM

Introduction

In the world of backend engineering, effective data modeling is a crucial aspect of building robust and scalable applications. Properly structuring and organizing your data sets the foundation for efficient data management and enables seamless interactions with your application's database. In this article, we will explore the realm of data modeling in the context of NestJS, and delve into the power of Prisma ORM.

Understanding Data Modeling

Data modeling is the process of designing the structure and organization of data within a database system. It involves defining the entities, attributes, relationships, and constraints that determine how data is stored, retrieved, and manipulated. A well-designed data model ensures data integrity, optimizes query performance, and provides a foundation for building complex applications.

Overview of Prisma ORM

Prisma ORM is a powerful tool that simplifies database operations and enhances data modeling capabilities in NestJS applications. It acts as a bridge between your application and the database, providing an intuitive and type-safe API for working with data. With Prisma ORM, you can seamlessly define, query, and manipulate your data using a declarative syntax, while maintaining full control over your database schema.

What this article offers you

By the end of this article, you will know to leverage Prisma ORM to its fullest potential, enabling you to design robust data models that drive the functionality and performance of your NestJS applications.

Join me as we unlock the world of data modeling with Prisma ORM in NestJS, and embark on a path toward efficient and scalable backend development.

The Project: TodoVista

In this tutorial guide, we are going to design the database for a task management app called TodoVista. This app allows users to create Todos, assign relevant tags to them, and even add comments for further collaboration. By diving into the development of TodoVista, we will showcase the practical implementation of data modeling techniques, such as defining entities, establishing relationships, and managing data associations.

Identify the Entities

From the project description, four distinct entities can be identified, which are:

  • User

  • Todo

  • Comment

  • Tag

Establish the Model Relationships

Based on the project description, the identified relationships among the entities are as follows:

  1. User to Todo: One-to-Many relationship i.e. each user can create multiple todos.

  2. User to Comment: One-to-Many relationship i.e. each user can make multiple comments on a Todo.

  3. Todo to Comment: One-to-Many relationship i.e. each todo can have multiple comments.

  4. User to Tag: One-to-Many relationship i.e. each user can create multiple tags.

  5. Todo to Tag: Many-to-Many relationship i.e. each todo can have multiple tags and each tag can have multiple todos.

The identified relationships provide an understanding of how the models are related and how they can be queried to retrieve data associated with the relationships. Below is an entity-relationship diagram to enhance our understanding of the database design.

Setting Up Prisma ORM In NestJS

To set up Prisma ORM, we need to first install the Prisma CLI. Navigate to the root directory of your NestJS project and run the following command:

npm install prisma --save-dev

Next up is to create a Prisma project with the following command:

npx prisma init

The command does two things:

  • creates a new directory called prisma that contains a file called schema.prisma

  • creates a .env file, by default consists of a DATABASE_URL variable, and can be used for defining other environment variables.

Now we need to connect Prisma to our database. And to do that, we will:

  • update the DATABASE_URL variable in the .env file to point to our database.

  • update the provider in schema.prisma file to the database you are using.

Prisma supports all major relational databases such as PostgreSQL, MySQL, SQLite etc. And so the format of the connection URL depends on the database you are using, but the methodology remains the same. In this tutorial, we are going to be using the PostgreSQL database.

Below is the format of Postgres database connection string:

DATABASE_URL="postgresql://DB_USER_NAME:DB_USER_PASSWORD@HOST:PORT/DB_NAME?schema=public"

Now we update the DATABASE_URL variable to connect to the localhost Postgres server:

DATABASE_URL="postgresql://timothy:password123@localhost:5432/todovista?schema=public"

Lastly, ensure that the provider in schema.prisma file is set to "postgres", which is most likely there by default.

Our schema.prisma file should now look like this:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

Caveat: ConfigModule

For the DATABASE_URL variable to get picked up from the .env file, we need to ensure that ConfigModule is configured for our NestJS project. And to do that, we first install the package from npm:

npm i --save @nestjs/config

After that, we will import the ConfigModule into the AppModule in app.module.ts file and declare it as a global module for it to be available in all modules of our NestJS project.

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
... // other imports

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

Defining Models with Prisma

Now from our identified entities and relationships, we will be defining the data models and relationships using Prisma schema language(PSL). To do that, we will add the following Prisma data model to our Prisma Schema in schema.prisma file.

Data model for User entity:

// Data model for the User entity
model User {
  id        Int      @id @default(autoincrement()) // Auto-incrementing unique identifier for users
  email     String   @unique                       // Unique email for each user
  username  String                                 // User's username
  password  String                                 // User's password
  createdAt DateTime @default(now())               // Creation timestamp for user
  updatedAt DateTime @updatedAt                    // Update timestamp for user
  // ******Relationships*************
  todos     Todo[]                                 // One-to-many relationship with Todo entity (user can have multiple todos)
  tags      Tag[]                                  // One-to-many relationship with Tag entity (user can have multiple tags)
}

Data model for Todo entity:

// Data model for the Todo entity
model Todo {
  id          Int       @id @default(autoincrement()) // Auto-incrementing unique identifier for todos
  title       String                                  // Title of the todo
  description String?                                 // Optional description for the todo
  status      Status    @default(TODO)                // Status of the todo (enum: TODO, DOING, AWAITING, DONE, DISCONTINUED)
  createdAt   DateTime  @default(now())               // Creation timestamp for todo
  updatedAt   DateTime  @updatedAt                    // Update timestamp for todo
  // ******Relationships*************
  userId      Int                                     // Foreign key referencing the User entity (user who created the todo)
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade) // Relation to the User entity
  comments    Comment[]                               // One-to-many relationship with Comment entity (todo can have multiple comments)
  tags        Tag[]                                   // Many-to-many relationship with Tag entity (todo can have multiple tags)
}

Data model for Comment entity:

// Data model for the Comment entity
model Comment {
  id        Int      @id @default(autoincrement()) // Auto-incrementing unique identifier for comments
  text      String   @db.VarChar(120)              // Text of the comment (limited to 120 characters in the database)
  userEmail String                                 // User who made the comment
  createdAt DateTime @default(now())               // Creation timestamp for comment
  updatedAt DateTime @updatedAt                    // Update timestamp for comment
  // ******Relationships*************
  todoId    Int                                    // Foreign key referencing the Todo entity (todo to which the comment belongs)
  todo      Todo     @relation(fields: [todoId], references: [id], onDelete: Cascade) // Relation to the Todo entity
}

Data model for Tag entity:

// Data model for the Tag entity
model Tag {
  id     Int    @id @default(autoincrement()) // Auto-incrementing unique identifier for tags
  title  String @db.VarChar(20)               // Title of the tag (limited to 20 characters in the database)
  // ******Relationships*************
  todos  Todo[]                               // Many-to-many relationship with Todo entity (tag can be associated with multiple todos)
  userId Int                                  // Foreign key referencing the User entity (user who created the tag)
  User   User   @relation(fields: [userId], references: [id], onDelete: Cascade) // Relation to the User entity
  // ******Constraint*************
  @@unique([userId, title])                   // Ensuring that the combination of userId and title is unique for each tag
}

Putting it all together, our schema.prisma file should now look like this:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id            Int      @id @default(autoincrement())
  email         String   @unique
  username      String
  password      String
  todos         Todo[]
  tags          Tag[]
  refresh_token String?
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt
}

model Todo {
  id          Int       @id @default(autoincrement())
  title       String
  description String?
  status      Status    @default(TODO)
  userId      Int
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
  comments    Comment[]
  tags        Tag[]

  @@unique([id, userId])
}

model Comment {
  id        Int      @id @default(autoincrement())
  text      String   @db.VarChar(120)
  todoId    Int
  todo      Todo     @relation(fields: [todoId], references: [id], onDelete: Cascade)
  userEmail      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Tag {
  id     Int    @id @default(autoincrement())
  title  String @db.VarChar(20)
  todos  Todo[]
  User   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId Int

  @@unique([userId, title])
}

enum Status {
  TODO
  DOING
  AWAITING
  DONE
  DISCONTINUED
}

To facilitate comprehension, below is a visual representation of the schema.prisma file generated from the Prismaliser app

Creating the Database Tables

What we need to do now is to create the actual tables in our database. And to do that, we will use Prisma Migrate. Run the following command in the root of your NestJS project directory:

npx prisma migrate dev --name first_migration

This command does two things:

  • Firstly, it creates a new directory named migration in our prisma directory. Which also contains a directory named after the name given to the migration, in this case first_migration

  • Secondly, it runs the SQL migration file generated in the first step against the database to create our tables.

Tip: Prisma Studio

One of the awesome features of Prisma ORM is the visual editor provided to visualize the data in our database. To access this, run this command at the root of your project:

npx prisma studio

Copy the URL output from the command and paste it into your browser. Voila! You would see all our database tables.

Application Code to Prisma

All we've done before now is connect Prisma to our database. Now we are going to connect our application code to Prisma and expose Prisma to all modules in our NestJS project. To do this, we will use the Prisma client, which exposes CRUD operations to our defined models. Install the Prisma client by running the following commands:

npm install @prisma/client

After that is installed, we will create a NestJS module called prisma, and we will also create a service file in it. Run the following command:

nest g module prisma
nest g service prisma --no-spec

You can give this module any name you like, but I decided to name it prisma to identify with the concern with which it is created.

Next up is to go into our prisma.service.ts file and fill in the following code

import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  // Lifecycle hook: called once the module is initialized
  async onModuleInit() {
    // Establish a connection to the database
    await this.$connect();
  }

  // Method to enable shutdown hooks for graceful application shutdown
  async enableShutdownHooks(app: INestApplication) {
    // Listen for the 'beforeExit' event of PrismaClient
    this.$on('beforeExit', async () => {
      // Gracefully close the Nest.js application
      await app.close();
    });
  }
}

Now that prisma client is connected to the database, we need to expose this service to all the modules in our NestJS project.

  • Firstly, export the PrismaService in prisma.module.ts file and the Global decorator
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()        // add this
@Module({
  providers: [PrismaService],
  exports: [PrismaService]     // add this
})
export class PrismaModule {}
  • Secondly, import the PrismaModule in app.module.ts file
import { Module } from '@nestjs/common';
import {ConfigModule} from '@nestjs/config'
import { PrismaModule } from './prisma/prisma.module';

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

CRUD Operations with Prisma

Now that we have everything connected, we can now begin to write services that include queries to read and write data in our database. Below is a simple Tag service that includes API for creating and deleting a Tag.

import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { CreateTagDto } from './dto';

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

    // ---------CREATE A TAG---------------
    async createTag(userId:number, dto:CreateTagDto){
        try {
            const tag = await this.prisma.tag.create({
                data: {
                    title: dto.title,
                    userId: userId
                }
            })

            return tag
        } catch (error) {
            if(error.code === 'P2002') throw new HttpException('Tag already exist', HttpStatus.CONFLICT)

            throw new HttpException('An error occured', HttpStatus.INTERNAL_SERVER_ERROR)
        }
    }

    // ---------------DELETE A TAG--------------
    async deleteTag(userId:number, id:number){
        try {
            await this.prisma.tag.deleteMany({
                where:{
                    id: id,
                    userId: userId
                }
            })
            return;
        } catch (error) {
            throw new HttpException('An error occured', HttpStatus.INTERNAL_SERVER_ERROR)
        }
    }
}

Conclusion

In conclusion, we have explored the power of data modeling with Prisma ORM in NestJS, witnessing how it enhances the structure and behavior of backend applications. The implementation of TodoVista app exemplified essential concepts such as entity-relationship modeling and data associations.

The full TodoVista app is available on my GitHub repository at github.com/Timothy-py/TodoVista. Armed with this knowledge, you can now build scalable and intuitive backend applications with efficient data management. Thank you for joining me on this enlightening journey, and happy coding!