Handle Database data integrity with the Prisma ORM

Photo by NEOM / Unsplash
Photo by NEOM / Unsplash

Cloud-hosted databases are gaining in popularity among developers for their scalability, no infrastructure management, pricing model, developer experience, etc... Some of these cloud-hosted databases made a choice not to support some important database features in favor of better performance, such as PlanetScale, a Serverless database for MySQL.

An important feature is the integrity constraints check that prevents storing inconsistent data in the database. If you are not familiar with database and SQL concepts, check out this link to this book to help you better understand.

For developers wanting this feature, the ORM Prisma built an interesting feature called relation mode that emulates the integrity constraints check at the application level.

This post will show how to enable and use it in a Node.js application.

Use case

We want to build a task manager where users can sign up and manage their tasks. A user can create many tasks, and a task always has an owner, the user who added the task.

The database schema can look like the picture below:

The diagram of the database schema.
The diagram of the database schema.

In this post, we will use the Prisma relation mode feature to:

  • Prevent creating a task with a user that doesn't exist.
  • Prevent deleting a user having tasks associated with him.
  • Perform cascading deletion of tasks when deleting a user.

Prerequisites

You need these tools installed on your computer to follow this tutorial.

The PlanetScale free plan offers two branches; one is used for the main branch, and the second for the development branch. Since we will use Prisma ORM, it requires a shadow database when working with a cloud-hosted database.

This shadow database cannot be created on PlanetScale free plan, so we will use Docker to create one and use it. If you have a paid plan on PlanetScale, you can create a third database and avoid using Docker.

Run the command below to create the shadow database:


docker run -d --rm -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=task-shadow --name task-shadow-db -p 3309:3306 mysql:8.0.23

The connection string to the shadow database is mysql://root:secret@localhost:3309/task-shadow

To learn more about why Prisma requires a shadow database, check out this blog post I wrote.

Why you need a shadow database when using the Prisma ORM
When running a Prisma migration on a cloud-hosted database, you need a shadow database to see your migration succeed. In this post, we will see why it is required.

Set up the project

We will use the Node.js starter project we built in this post; run the commands below to initialize the project and Prisma in the project:


git clone https://github.com/tericcabrel/node-ts-starter.git node-prisma-data-integrity

cd node-prisma-data-integrity

yarn install

yarn add -D prisma

yarn prisma init

Set up the database credentials

A .env file has been generated in the project root directory, and we must set two properties in the file DATABASE_URL and SHADOW_DATABASE_URL`.

The DATABASE_URL is the connection string to the development database branch.

The PlanetScale CLI provides a command to create a tunnel to a remote database. Run the command below:


pscale connect <database_name> <branch_name> --port 3308

My .env file looks like this:


DATABASE_URL="mysql://root@localhost:3308/task-manager-db"
SHADOW_DATABASE_URL="mysql://root:secret@localhost:3309/task-shadow"

Initialize the database schema

Replace the content of the file prisma/schema.prisma with the code below:


datasource db {
  provider          = "mysql"
  url               = env("DATABASE_URL")
  shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
}

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

enum TaskStatus {
  PENDING
  IN_PROGRESS
  DONE
}

model User {
  id          Int         @id @default(autoincrement())
  firstName    String      @map("first_name")
  lastName    String      @map("last_name")
  email       String
  password    String
  createdAt   DateTime    @default(now()) @map("created_at")
  updatedAt   DateTime    @updatedAt @map("updated_at")
  tasks       Task[]

  @@map("users")
}

model Task {
  id          Int         @id @default(autoincrement())
  name        String
  description String      @db.Text
  status      TaskStatus  @default(PENDING)
  dueDate     DateTime    @map("due_date")
  createdAt   DateTime    @default(now()) @map("created_at")
  updatedAt   DateTime    @updatedAt @map("updated_at")
  owner       User        @relation(fields: [ownerId], references: [id], onDelete: Restrict)
  ownerId     Int         @map("owner_id")

  @@map("tasks")
}

Let's run the command below to generate SQL for table creation from the Prisma schema:


yarn prisma migrate dev --name init-db-table

We get the following output:

Fail to execute the migrations because of usage of foreign keys constraint.
Fail to execute the migrations because of usage of foreign keys constraint.

We can clearly see that the error indicates that foreign key constraints are not allowed, and it is because of the line below in the Prisma schema:


owner       User        @relation(fields: [ownerId], references: [id], onDelete: Restrict)

Which generates the following SQL:


ALTER TABLE `tasks` ADD CONSTRAINT `tasks_owner_id_fkey` FOREIGN KEY (`owner_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

This constraint prevents the deletion of the user who owns a task because a task must always have an owner.

This is a constraint you must want to keep, but the reason Prisma asks you to delete it is related to PlanetScale, which is a cloud-hosted database. Check out the link below to learn more about the non-support of foreign keys constraint.

Operating without foreign key constraints — PlanetScale Documentation
How to manage your relational data without formal foreign key constraints in your schema

Enable the relation mode on Prisma

To fix the issue, The Prisma engine has a built-in feature to handle integrity constraints at the application level. This means the database schema will not have the foreign keys constraint, and Prisma will manage it.

  • If you want to create a task with a user ID that doesn't exists in the users' table, Prisma will check it and throw an error.
  • If you delete a user having tasks, Prisma will check it before performing the action.

The referential actions supported for MySQL are Cascade, Restrict, NoAction, and SetNull. The referential action SetDefault is not supported.

To enable it, update the prisma/prisma.schema file to add the code below:


datasource db {
  provider          = "mysql"
  url               = env("DATABASE_URL")
  shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
  relationMode      = "prisma"
}

Re-run the command to generate the database schema from the Prisma schema.

The migration to create the database table was executed successfully.
The migration to create the database table was executed successfully.

If you check out the migration.sql file, you can see no SQL query related to defining integrity constraints.

Seed the database with some users

Create file named prisma/seed.ts and add the code below:


import { PrismaClient } from '@prisma/client';

(async () => {
  const { randomBytes } = await import('crypto');

  const prismaClient = new PrismaClient();

  try {
    await prismaClient.$connect();

    await prismaClient.user.createMany({
      data: [
        {
          firstName: 'John',
          lastName: 'Doe',
          email: 'john.doe@email.com',
          password: randomBytes(10).toString('hex'),
        },
        {
          firstName: 'Jane',
          lastName: 'Doe',
          email: 'jane.doe@email.com',
          password: randomBytes(10).toString('hex'),
        },
      ],
      skipDuplicates: true,
    });
  } catch (e) {
    console.error(e);
  } finally {
    await prismaClient.$disconnect();
  }
})();

Add the following code to the project package.json


"prisma": {
    "seed": "ts-node ./prisma/seed.ts"
}

Run the command below to execute the seed file using Prisma.


yarn prisma db seed

Now we have two users in the database with IDs 1 and 2.

Prisma relation mode in action

To showcase the Prisma relation mode, we will try to execute some actions and see the output.

Add a task with a user that doesn't exist

Create a file named create-task.ts and add the code below:


import { PrismaClient, TaskStatus } from '@prisma/client';

(async () => {
  const prismaClient = new PrismaClient({
    log: ['query'],
  });

  try {
    await prismaClient.$connect();

    await prismaClient.task.create({
      data: {
        name: 'Task One',
        description: 'Description of task one',
        dueDate: new Date(2023, 4, 31),
        status: TaskStatus.PENDING,
        owner: {
          connect: {
            id: 100,
          },
        },
      },
    });
  } catch (e) {
    console.error(e);
  } finally {
    await prismaClient.$disconnect();
  }
})();

We try to create a task with the user ID 100 that doesn't exists. We also enabled the SQL query log to see what happens under the hood.

Run the command yarn ts-node ./src/create-task.ts

Cannot create a task with a user that doesn't exists.
Cannot create a task with a user that doesn't exists.

From the SQL query log, we can see that Prisma tried first to find the user having ID 100. Since it didn't find it, it threw an error that prevented inserting inconsistent data in the database.

Replacing with a valid owner ID creates the task successfully.


owner: {
	connect: {
    	id: 2,
    },
},

Important notes:

Creating a user using by assigning the ownerId instead of owner will not perform the integrity check and thus insert invalid data in the database.


await prismaClient.task.create({
    data: {
      name: 'Task One',
      description: 'Description of task one',
      dueDate: new Date(2023, 4, 31),
      status: TaskStatus.PENDING,
      ownerId: 100,
    },
});

Always keep this in mind when the relation mode is set to Prisma.

Delete a user having a task

Create a file named delete-task.ts and add the code below:


import { PrismaClient } from '@prisma/client';

(async () => {
  const prismaClient = new PrismaClient({
    log: ['query'],
  });

  try {
    await prismaClient.$connect();

    await prismaClient.user.delete({
      where: {
        id: 2,
      },
    });
  } catch (e) {
    console.error(e);
  } finally {
    await prismaClient.$disconnect();
  }
})();

Run the command yarn ts-node ./src/create-task.ts

Cannot delete a user associated with some tasks.
Cannot delete a user associated with some tasks.

The Prisma client tried first to find all the tasks associated with the user we wanted to delete. This query returned a non-empty result meaning we cannot delete the user, which is the message thrown by the Prisma client.

What about cascading deletion?

It is great to prevent deleting a user having a task. Still, if we want to delete all the tasks associated with the user, we can update the relationship in the Prisma schema to allow cascading deletion.


owner       User        @relation(fields: [ownerId], references: [id], onDelete: Cascade)

Since there are no database changes, run the command yarn prisma generate so that the Prisma client gets the update of the relation mode.

Re-run the command to delete the user again.

Delete the user with his tasks thank to cascading deletion.
Delete the user with his tasks thank to cascading deletion.

We can see the Prisma engine deleted the task associated first and then deleted the user. From the application side, the experience is the same as working with a database supporting foreign key constraints, but Prisma handles the dirty task for us under the hood.

The caveat about the Prisma relation mode

As you can guess, handling integrity constraints at the application level by the Prisma client will impact application performance because the client makes additional database queries, as we saw in the previous examples.

Always let the database handle data integrity whenever possible to not impact application performance. The default relation mode is set to foreignKeys so you don't even have to define it in the Prisma schema.

Wrap up

The Prisma relation mode is very helpful when working with databases that don't support integrity constraints, such as MongoDB, Serverless MySQL databases, cloud-hosted databases, etc...

When enabled, the Prisma client will emulate the foreign keys constraint check and referential actions such as cascading deletion. Pay attention to how you define the query using the Prisma client to avoid storing inconsistent data.

Check out the Prisma documentation about the Prisma relation mode.

You can find the code source on the GitHub repository.

Follow me on Twitter or subscribe to my newsletter to avoid missing the upcoming posts and the tips and tricks I occasionally share.