Implementing relationships with MongoDB
An essential thing about MongoDB is that it is non-relational. Therefore, it might not be the best fit if relationships are a big part of our database design.…
An essential thing about MongoDB is that it is non-relational. Therefore, it might not be the best fit if relationships are a big part of our database design. That being said, we definitely can mimic SQL-style relations by using references of embedding documents directly. You can get all of the code from this article in this repository. Defining the initial schema In this article, we base the code on many of the functionalities we’ve implemented in the previous parts of this series. If you want to know how we register and authenticate users, check out API with NestJS #3. Authenticating users with bcrypt, Passport, JWT, and cookies. Let’s start by defining a schema for our users. user.schema.ts import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Document } from 'mongoose'; import { Exclude, Transform } from 'class-transformer'; export type UserDocument = User & Document; @Schema() export class User { @Transform(({ value }) => value.toString()) _id: string; @Prop({ unique: true }) email: string; @Prop() name: string; @Prop() @Exclude() password: string; } export const UserSchema = SchemaFactory.createForClass(User);A few significant things are happening above. We use unique: true above to make sure that all users have unique emails. It sets up unique indexes under the hood and deserves a separate article. The @Exclude and @Transform decorators come from the class-transformer library. We cover serialization in more detail in API with NestJS #5. Serializing the response with interceptors. There is a significant catch here with MongoDB and Mongoose, though. The Mongoose library that we use for connecting to MongoDB and fetching entities does not return instances of our User class. Therefore, the ClassSerializerInterceptor won’t work out of the box. Let’s change it a bit using the mixin pattern. mongooseClassSerializer.interceptor.ts import { ClassSerializerInterceptor, PlainLiteralObject, Type, } from '@nestjs/common'; import { ClassTransformOptions, plainToClass } from 'class-transformer'; import { Document } from 'mongoose'; function MongooseClassSerializerInterceptor( classToIntercept: Type, ): typeof ClassSerializerInterceptor { return class Interceptor extends ClassSerializerInterceptor { private changePlainObjectToClass(document: PlainLiteralObject) { if (!(document instanceof Document)) { return document; } return plainToClass(classToIntercept, document.toJSON()); } private prepareResponse( response: PlainLiteralObject | PlainLiteralObject[], ) { if (Array.isArray(response)) { return response.map(this.changePlainObjectToClass); } return this.changePlainObjectToClass(response); } serialize( response: PlainLiteralObject | PlainLiteralObject[], options: ClassTransformOptions, ) { return super.serialize(this.prepareResponse(response), options); } }; } export default MongooseClassSerializerInterceptor; I wrote the above code with the help of Jay McDoniel. The official NestJS discord is a great place to ask for tips. Above, we change MongoDB documents into instances of the provided class. Let’s use it with our controller: authentication.controller.ts import { Body, Controller, Post, UseInterceptors, } from '@nestjs/common'; import { AuthenticationService } from './authentication.service'; import RegisterDto from './dto/register.dto'; import { User } from '../users/user.schema'; import MongooseClassSerializerInterceptor from '../utils/mongooseClassSerializer.interceptor'; @Controller('authentication') @UseInterceptors(MongooseClassSerializerInterceptor(User)) export class AuthenticationController { constructor(private readonly authenticationService: AuthenticationService) {} @Post('register') async register(@Body() registrationData: RegisterDto) { return this.authenticationService.register(registrationData); } // ... }Thanks to doing the above, we exclude the password when returning the data of the user. One-To-One With the one-to-one relationship, the document in the first collection has just one matching document in the second collection and vice versa. Let’s create a schema for the address: address.schema.ts import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Document } from 'mongoose'; import { Transform } from 'class-transformer'; export type AddressDocument = Address & Document; @Schema() export class Address { @Transform(({ value }) => value.toString()) _id: string; @Prop() city: string; @Prop() street: string; } export const AddressSchema = SchemaFactory.createForClass(Address);There is a big chance that just one user is assigned to a particular address in our application. Therefore, it is a good example of a one-to-one relationship. Because of that, we can take advantage of embedding documents, which is an approach very good performance-wise. For it to work properly, we need to explicitly pass AddressSchema to the @Prop decorator: user.schema.ts import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Document, ObjectId } from 'mongoose'; import { Exclude, Transform, Type } from 'class-transformer'; import { Address, AddressSchema } from './address.schema'; export type UserDocument = User & Document; @Schema() export class User { @Transform(({ value }) => value.toString()) _id: ObjectId; @Prop({ unique: true }) email: string; @Prop() name: string; @Prop() @Exclude() password: string; @Prop({ type: AddressSchema }) @Type(() => Address) address: Address; } export const UserSchema = SchemaFactory.createForClass(User); We use @Type(() => Address) above to make sure that the class-transformer transforms the Address object too. When we create the document for the user, MongoDB also creates the document for the address. It also gives it a distinct id. In our one-to-one relationship example, the user has just one address. Also, one address belongs to only one user. Since that’s the case, it makes sense to embed the user straight into the user’s document. This way, MongoDB can return it fast. Let’s use MongoDB Compass to make sure that this is the case here. One-To-Many We implement the one-to-many and many-to-one relationships when a document from the first collection can be linked to multiple documents from the second collection. Documents from the second collection can be linked to just one document from the first collection. Great examples are posts and authors where the user can be an author of multiple posts. In our implementation, the post can only have one author, though. post.schema.ts import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Document, ObjectId } from 'mongoose'; import * as mongoose from 'mongoose'; import { User } from '../users/user.schema'; import { Transform, Type } from 'class-transformer'; export type PostDocument = Post & Document; @Schema() export class Post { @Transform(({ value }) => value.toString()) _id: ObjectId; @Prop() title: string; @Prop() content: string; @Prop({ type: mongoose.Schema.Types.ObjectId, ref: User.name }) @Type(() => User) author: User; } export const PostSchema = SchemaFactory.createForClass(Post);Thanks to defining the above reference, we can now assign the user to the author property in the post. posts.service.ts import { Model } from 'mongoose'; import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Post, PostDocument } from './post.schema'; import PostDto from './dto/post.dto'; import { User } from '../users/user.schema'; @Injectable() class PostsService { constructor(@InjectModel(Post.name) private postModel: Model