Mastering NestJS: Unlocking the Power of Relationships with TypeORM and SQL Databases

Introduction

Welcome to "Mastering NestJS: Unlocking the Power of Relationships with TypeORM and SQL Databases." In this blog post, we'll look at how NestJS, in conjunction with TypeORM and SQL databases, can help you build complicated data structures and handle interactions between them. By the end of this tutorial, you'll be able to construct APIs that handle complex data connections with ease, taking your NestJS knowledge to the next level.

Whether you're a seasoned NestJS developer hoping to expand your knowledge or a beginner eager to master the ins and outs of data relationships, this comprehensive investigation of data relationships will provide you with the experience needed to construct cutting-edge apps. So, let's get started and learn how to design strong, interconnected systems with NestJS, TypeORM, and SQL databases.

I created a GitHub repo for this series accessible at the following address

What are relations

Relations are connections formed between two or more tables. Relations are formed using common fields from each table, which frequently include primary and foreign keys.

There are three kinds of relationships:

  1. One-to-one: Every row in the primary table has exactly one row in the foreign table. To define this type of relationship, use the @OneToOne() decorator.
  2. One-to-many/Many-to-one: Every row in the primary table is connected to one or more rows in the foreign table. To define this type of relationship, we use the @OneToMany() and @ManyToOne() decorators.
  3. Many-to-many: Every row in the primary table has many related rows in the foreign table, and every record in the foreign table has many related rows in the primary table. Use the @ManyToMany() decorator to define this type of relation.

We will now go over each of these terms in detail

One-To-Many

One-to-one is a relation where A contains only one instance of B, and B contains only one instance of A. Let's take for example User and Profile entities. User can have only a single profile, and a single profile is owned by only a single user.

Since we were going to implement authentication and authorization in the next post, we should start creating related resources. So let's create profiles and users Rest API resources.

bash
     nest g resource profiles
     nest g resource users

See the following output for creating profiles resource

Create nest resource

Let's explore user.entity.ts

user.entity.ts
import { UserRole } from 'src/enums/user.role';
import { Profile } from 'src/profiles/entities/profile.entity';
import { Column, Entity, OneToOne, PrimaryGeneratedColumn } from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column({ unique: true })
  email: string;

  @Column()
  password: string;

  @Column({ type: 'enum', enum: UserRole, default: UserRole.CUSTOMER })
  role: string;
}

The UserRole enum is available in the source code. You should take a look at it if you want to see it is implemented

Now let's explore the profile.entity.ts content

profile.entity.ts
import { User } from 'src/users/entities/user.entity';
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';

@Entity('profiles')
export class Profile {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ nullable: true })
  full_address?: string;

  @Column({ nullable: true })
  photo?: string;

  @OneToOne(() => User, (user) => user.profile) // specify inverse side as a second parameter
  @JoinColumn()
  user: User;
}

Above, we used the @OneToOne() decorator. Its argument is a function that returns the class of the entity with which we wish to establish a relationship.

The @JoinColumn() decorator specifies that the relationship is owned by the Profile entity. It signifies that the rows of the Profile table contain the userId column, which can store a user's id. We only use it on one side of the relationship.

Bidirectional relation

Our relationship is currently unidirectional. Meaning that just one side of the relationship knows anything about the other. With TypeORM, relations can be uni-directional and bidirectional. Uni-directional is relations with a relation decorator only on one side. Bidirectional is relations with decorators on both sides of a relation.

We just created a uni-directional relation. Let's make it bidirectional:

profile.entity.ts
import { UserRole } from 'src/enums/user.role';
import { Profile } from 'src/profiles/entities/profile.entity';
import { Column, Entity, OneToOne, PrimaryGeneratedColumn } from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column({ unique: true })
  email: string;

  @Column()
  password: string;

  @Column({ type: 'enum', enum: UserRole, default: UserRole.CUSTOMER })
  role: string;

  @OneToOne(() => Profile, (profile) => profile.user)
  profile: Profile;
}

It's worth noting that the inverse relationship is a somewhat abstract idea that does not add any new columns to the database.

We just made our relations bidirectional. Note, inverse relation does not have a @JoinColumn. `@JoinColumn must only be on one side of the relation - on the table that will own the foreign key

How to save and retrieve one-to-one relation

user.service.ts
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { Profile } from 'src/profiles/entities/profile.entity';
import { UpdateProfileDto } from 'src/profiles/dto/update-profile.dto';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User) private userRepository: Repository<User>,
    @InjectRepository(Profile) private profileRepository: Repository<Profile>,
  ) {
  }

  async create(createUSerDto: CreateUserDto) {
    const user = await this.userRepository.save(createUSerDto); // saving the user

    // creating the the profile object
    const profile = new Profile();
    profile.full_address = createUSerDto.full_address;
    profile.photo = createUSerDto.photo;
    profile.user = user;

    await this.profileRepository.save(profile); // linking the profile to user

    return this.findOne(user.id); // return the user with the profile
  }

  async findOne(id: number) {
    const user = await this.userRepository.findOne({
      where: { id },
      relations: { profile: true }, // by doing this, we're implementing the eager loading to automatically load the profile object
    });

    if (!user) throw new HttpException('user not found', HttpStatus.NOT_FOUND);

    return user;
  }
}

Many-To-One / One-To-Many Relations

Many-to-one / one-to-many is a relation where A contains multiple instances of B, but B contains only one instance of A. Let's take for example User and Order entities. A user can have multiple orders, but each order is owned by only one single user.

Let's quickly set up orders REST API resource.

bash
     nest g resource orders
user.entity.ts
import { Order } from 'src/orders/entities/order.entity';

@Entity('users')
export class User {
  // we add the following to user entity
  @OneToMany(() => Order, (order) => order.user)
  orders: Order[];
}
order.entity.ts
import { OrderStatus } from 'src/enums/order.status';
import { User } from 'src/users/entities/user.entity';
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';

@Entity('orders')
export class Order {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'enum', enum: OrderStatus, default: OrderStatus.PLACED })
  status: string; // the order status

  @Column()
  amount: number;

  @ManyToOne(() => User, (user) => user.orders)
  user: User;

  @CreateDateColumn()
  created_datetime?: Date;
}

We added @OneToMany to orders property and set Order as the target relation type. In a @ManyToOne / @OneToMany relation, you can omit @JoinColumn. @ManyToOne cannot exist without @OneToMany. @ManyToOne is required if you want to use @OneToMany. However, if you only care about the @ManyToOne relationship, you can define it without having @OneToMany on the associated entity. Wherever @ManyToOne is configured, its linked entity will have a "relation id" and a foreign key.

How to save and retrieve one-to-many/many-to-one relation

Let me show the full order service that implements an order crud

order.Service.ts
import { Injectable } from '@nestjs/common';
import { CreateOrderDto } from './dto/create-order.dto';
import { UpdateOrderDto } from './dto/update-order.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { Order } from './entities/order.entity';
import { Repository } from 'typeorm';
import { UsersService } from 'src/users/users.service';

@Injectable()
export class OrdersService {
  constructor(
    @InjectRepository(Order)
    private readonly orderRepository: Repository<Order>,
    private readonly userService: UsersService,
  ) {
  }

  async create(userId: number, createOrderDto: CreateOrderDto) {
    const user = await this.userService.findOne(userId);

    const order = new Order();
    order.amount = createOrderDto.amount;
    order.user = user;

    return await this.orderRepository.save(order);
  }

  async findAll(userId: number): Promise<Order[]> {
    const user = await this.userService.findOne(userId);
    return await this.orderRepository.find({
      where: { user },
    });
  }

  async findOne(id: number): Promise<Order | null> {
    return await this.orderRepository.findOneBy({ id });
  }
}

Many-To-Many relations

Many-to-many is a relationship in which entity A contains multiple instances of entity B, and vice versa. For example, let's consider the entities 'Product' and 'Order.' An order can include multiple products, and each product can belong to multiple orders.

Let's explore the profile.entity.ts content

order.entity.ts
import { Product } from 'src/products/entities/product.entity';
import { ManyToMany, JoinTable } from 'typeorm';

@Entity('orders')
export class Order {
  // ..... other properties

  // our new property
  @ManyToMany(() => Product)
  products: Product[];
}

@JoinTable() is required for @ManyToMany relations. You must put @JoinTable on one (owning) side of relation.

Saving many-to-many relations

Let's update the order service file and add some new lines to create method

order.Service.ts
export class OrdersService {
  // ....previous stuffs 

  async create(userId: number, createOrderDto: CreateOrderDto) {
    // ..... previous stuffs

    const products = [];

    for (const product of createOrderDto.products) {
      try {
        const dbProduct = await this.productService.findOne(product);
        products.push(dbProduct);
      } catch (error) {
        // We'll update this later
        console.log('failed to find product with id ' + product);
      }
    }

    order.products = products;

    // .... previous stuffs
  }
}

Loading Many-to-Many relations

To load orders with products inside you, must specify the relation in FindOptions:

order.Service.ts
export class OrdersService {
  // ....previous stuffs 

  async findOne(id: number): Promise<Order | null> {
    return await this.orderRepository.findOne({
      relations: {
        products: true,
      },
      where: { id },
    });
  }
}

Summary

We delve deeply into the world of data relationships with NestJS, TypeORM, and SQL databases in this extensive blog post. This lesson will help you regardless of whether you're an experienced NestJS developer trying to broaden your expertise or a novice keen to learn the nuances of data connections. Stay tuned for the upcoming post, where we'll delve into data validation and error handling, equipping you with even more tools to become a NestJS expert. Join us on this exciting adventure, and let's master the art of data relationships in NestJS together!