Marc Wäckerlin
Für eine libertäre Gesellschaft

NestJS + MikroORM

März 21, 2023

Views: 478

In NestJS, we have been working with TypeORM. Then we tried out Sequelize in a project at Pacta, since it was suggested by an employee. Now I see, that NestJS supports MikroORM, so let’s check MikroORM.

The first very positive impression is the comprehensive documentation. I start a project for all ORMs in the same setup with 3 Entities: A book written by several authors (zero if author is unknown) and any number of publisher. The book title, the author’s name, etc. should be a list of strings, since a person may have multiple names and a book may have multiple titles. Let’s build a CRUD interface with the new ORMs.

Migrations

Migrations create the initial tables and care about database schema migrations. ORMs keep track of migrations. They store a table in the database, which migrations have been executed and which still need to b done. As a requirement, all outstanding migrations should be executed automatically at start of the server.

Project Setup

If not yet done, install NestJS command line tools: npm i -g @nestjs/cli. The project is setup as default NestJS project, choose npm as package manager:

nest new -p npm mikroorm-demo

Always first upgrade all packages to the lastest version. Call npm install -g npm-check-updates if ncu is not yet installed, then upgrade:

ncu -u
npm i

Generate a CRUD template for the three entities, choose REST API and CRUD entry points:

nest g resource book
nest g resource author
nest g resource publisher

Cleanup

The default template generates a sample app controller and service, just remove the three files: app.controller.spec.ts, app.controller.ts, app.service.ts, then remove controllers and providers from app.module.ts:

import { Module } from '@nestjs/common'
import { BookModule } from './book/book.module'
import { AuthorModule } from './author/author.module'
import { PublisherModule } from './publisher/publisher.module'

@Module({
  imports: [BookModule, AuthorModule, PublisherModule],
})
export class AppModule {}

Generation OpenAPI Documentation

Unfortunately, the NestJS standard template does not generate the OpenAPI JSON documentation. Install the OpenAPI / Swagger package:

npm i @nestjs/swagger

To be able to read the package name and version from package.json, add the following line to "compilerOptions" in tsconfig.json:

"resolveJsonModule": true

Then change src/main.ts to:

import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'
import { name, version, description } from '../package.json'

const Name = name // generate name from package definition
  .replace(/-[a-z]/, (g) => ' ' + g[1].toUpperCase())
  .replace(/^[a-z]/, (g) => g.toUpperCase())

async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  const options = new DocumentBuilder()
    .setTitle(Name + ' API definition')
    .setDescription(description)
    .setVersion(version)
    .addServer('http://localhost:4000', 'Testing environment.')
    .build()
  const document = SwaggerModule.createDocument(app, options)
  console.log(document)
  SwaggerModule.setup('api', app, document)
  await app.listen(Number(process.env.PORT ?? 4000), '0.0.0.0')
}
bootstrap()

Exception Handling

It is good practice to insert a global exception filter, that catches all exceptions and turns them into a negative answer on the interface.

Create a file src/exception-filter.ts:

import { NotFoundError } from '@mikro-orm/core'
import { Logger, Injectable, ExceptionFilter, Catch, ArgumentsHost, NotFoundException, HttpAdapterHost, HttpException, HttpStatus } from '@nestjs/common'

@Injectable()
@Catch()
export class AllExceptionFilter implements ExceptionFilter {
    private readonly logger = new Logger(AllExceptionFilter.name)
    constructor(private readonly httpAdapterHost: HttpAdapterHost) { }
    catch(exception, host: ArgumentsHost) {
        this.logger.warn('EXCEPTION', exception)
        if (exception instanceof NotFoundError) exception = new NotFoundException(exception)
        const { httpAdapter } = this.httpAdapterHost;

        const ctx = host.switchToHttp();

        const httpStatus =
            exception instanceof HttpException
                ? exception.getStatus()
                : HttpStatus.INTERNAL_SERVER_ERROR;

        const responseBody = {
            statusCode: httpStatus,
            timestamp: new Date().toISOString(),
            path: httpAdapter.getRequestUrl(ctx.getRequest()),
        };

        httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
    }
}

If an exception is thrown und it is an HttpException, the result is taken from the exception, otherwise an internal server error is returned – with one special case for MikroORM: MikroORM throws NotFoundError is a value is not found in the database. That should not result in a 505, but in a 404, so we change NotFoundError to NotFoundException.

Enable this and validation pipes globally in main.ts after app instantatiation:

import { ValidationPipe } from '@nestjs/common'
import { AllExceptionFilter } from './exception-filter'
…
  app.useGlobalPipes(newValidationPipe())
  app.useGlobalFilters(newAllExceptionFilter(app.get(HttpAdapterHost)))

MikroORM

MicroORM allows a much easier and more straight forward notation when combined with ts-morph, e.g. you don’t need to define @Property({nullable: true}) for optional properties, the ? is enough:

@Property()
name?: string

For that, we’ll need to set the TsMorphMetadataProvider below.

Setup

Install the necessary packages first. In this case, SQLite is good enough for a sample:

npm i @mikro-orm/core @mikro-orm/nestjs @mikro-orm/sqlite @mikro-orm/migrations @mikro-orm/reflection

Configure

In tsconfig.json, make sure the following lines exist in "compilerOptions":

"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"esModuleInterop": true,

In app.module.ts, add MikroOrmModule and the entities to imports:

…
import { MikroOrmModule } from '@mikro-orm/nestjs'
import { TsMorphMetadataProvider } from '@mikro-orm/reflection'
import { Author } from './author/entities/author.entity'
import { Book } from './book/entities/book.entity'
import { Publisher } from './publisher/entities/publisher.entity'

@Module({
  imports: [
    MikroOrmModule.forRoot({
      entities: [Author, Book, Publisher],
      dbName: 'test-db.sqlite3',
      type: 'sqlite',
      metadataProvider: TsMorphMetadataProvider,
      migrations: {
        path: `${__dirname}/../migrations`,
      },
    }),
    BookModule,
    …

As you see, in this example, I just use a SQLite database with file name test-db.sqlite3.

Run Migrations at Server Startup

To run migrations at server start in MikroORM, you need to add the following line to the bootstrap function in main.ts, right after creating the app from the Nestfactory:

app.get(MikroORM).getMigrator().up()

and import:

import { MikroORM } from '@mikro-orm/core'

Entities

Base Entity

All classes should have the same automatic basic functionalities:

  1. an id of type uuid as primary key
  2. a creation date in createdAt
  3. date of last update in updatedAt

Install uuid for v4():

npm i uuid

For this, we create a base class for entities in src/base/entities/base.entity.ts, the @Entity({ abstract: true }) annotation is only necessary for automated detection of entities in a given path:

import { Entity, PrimaryKey, Property } from '@mikro-orm/core'
import { v4 } from 'uuid'

@Entity({ abstract: true })
export abstract class Base {

  @PrimaryKey()
  id: string = v4()

  @Property()
  createdAt: Date = new Date()

  @Property({ onUpdate: () => new Date() })
  updatedAt: Date = new Date()

}

Author

Define the author in src/author/entities/author.entity.ts:

import { Entity, Property } from '@mikro-orm/core'
import { Base } from '../../base/entities/base.entity'
import { CreateAuthorDto } from '../dto/create-author.dto'

@Entity()
export class Author extends Base {
  constructor(createAuthorDto: CreateAuthorDto) {
    super()
    Object.assign(this, createAuthorDto)
  }

  @Property()
  first_names?: string[]

  @Property()
  last_names!: string[]

  @Property()
  born?: Date

  @Property()
  died?: Date
}

Publisher

Define the publisher in src/publisher/entities/publisher.entity.ts:

import { Entity, Property } from '@mikro-orm/core'
import { Base } from '../../base/entities/base.entity'
import { CreatePublisherDto } from '../dto/create-publisher.dto'

@Entity()
export class Publisher extends Base {
  constructor(createPublisherDto: CreatePublisherDto) {
    super()
    Object.assign(this, createPublisherDto)
  }

  @Property()
  publisher_names?: string[]

  @Property()
  publisher_address_lines?: string[]

}

Book

Define the book in src/book/entities/book.entity.ts:

import { Entity, Property } from '@mikro-orm/core'
import { Author } from 'src/author/entities/author.entity'
import { Publisher } from 'src/publisher/entities/publisher.entity'
import { Base } from '../../base/entities/base.entity'
import { CreateBookDto } from '../dto/create-book.dto'

@Entity()
export class Book extends Base {
  constructor(createBookDto: CreateBookDto) {
    super()
    Object.assign(this, createBookDto)
  }

  @Property()
  titles?: string[]

  @Property()
  authors?: Author[]

  @Property()
  publishers?: Publisher[]

  @Property()
  isbn?: string

}

Service Implementation

Now implement the services:

Author

import { Injectable } from '@nestjs/common'
import { EntityManager } from '@mikro-orm/core'
import { Author } from './entities/author.entity'
import { CreateAuthorDto } from './dto/create-author.dto'
import { UpdateAuthorDto } from './dto/update-author.dto'

@Injectable()
export class AuthorService {
  constructor(private readonly authorRepository: EntityManager) { }

  async create(createAuthorDto: CreateAuthorDto): Promise {
    const newAuthor = new Author(createAuthorDto)
    await this.authorRepository.persistAndFlush(newAuthor)
    return newAuthor
  }

  async findAll(query: Record<string, any> = {}): Promise<Author[]> {
    return await this.authorRepository.find(Author, query)
  }

  async findOne(id: string): Promise {
    return this.authorRepository.findOneOrFail(Author, id)
  }

  async update(id: string, updateAuthorDto: UpdateAuthorDto): Promise {
    return await this.authorRepository.transactional(async (em) => {
      const author = await em.findOneOrFail(Author, id)
      Object.assign(author, updateAuthorDto, { merge: true })
      await em.persistAndFlush(author)
      return author
    })
  }

  async remove(id: string): Promise {
    const author = await this.authorRepository.findOneOrFail(Author, id)
    await this.authorRepository.removeAndFlush(author)
    return author
  }

}

Publisher

import { Injectable } from '@nestjs/common'
import { Publisher } from './entities/publisher.entity'
import { CreatePublisherDto } from './dto/create-publisher.dto'
import { UpdatePublisherDto } from './dto/update-publisher.dto'
import { EntityManager } from '@mikro-orm/core'

@Injectable()
export class PublisherService {
  constructor(private readonly publisherRepository: EntityManager) { }

  async create(createPublisherDto: CreatePublisherDto) {
    const publisher = new Publisher(createPublisherDto)
    await this.publisherRepository.persistAndFlush(publisher)
    return publisher
  }

  async findAll(query: Record<string, any> = {}): Promise<Publisher[]> {
    return this.publisherRepository.find(Publisher, query)
  }

  async findOne(id: string): Promise {
    return this.publisherRepository.findOneOrFail(Publisher, id)
  }

  async update(id: string, updatePublisherDto: UpdatePublisherDto): Promise {
    return await this.publisherRepository.transactional(async (em) => {
      const publisher = await em.findOneOrFail(Publisher, { id })
      Object.assign(publisher, updatePublisherDto, { merge: true })
      await em.persistAndFlush(publisher)
      return publisher
    })
  }

  async remove(id: string): Promise {
    const publisher = await this.publisherRepository.findOneOrFail(Publisher, id)
    await this.publisherRepository.removeAndFlush(publisher)
    return publisher
  }
}

Book

The book is a little bit different, because it unidirectionally links to Publisher and Author.

import { Injectable, Logger, NotFoundException } from '@nestjs/common'
import { Book } from './entities/book.entity'
import { CreateBookDto } from './dto/create-book.dto'
import { UpdateBookDto } from './dto/update-book.dto'
import { EntityManager } from '@mikro-orm/core'
import { Publisher } from '../publisher/entities/publisher.entity'
import { Author } from '../author/entities/author.entity'

@Injectable()
export class BookService {
  private readonly logger = new Logger(BookService.name)
  constructor(private readonly em: EntityManager) { }

  async create(createBookDto: CreateBookDto): Promise {
    return await this.em.transactional(async (em) => {
      const authors = await em.find(Author, { id: { $in: createBookDto.authors ?? [] } })
      if ((authors?.length ?? 0) !== (createBookDto?.authors?.length ?? 0)) throw new NotFoundException('author not found')
      const publishers = await em.find(Publisher, { id: { $in: createBookDto.publishers ?? [] } })
      if ((publishers?.length ?? 0) !== (createBookDto?.publishers?.length ?? 0)) throw new NotFoundException('publisher not found')
      const book = new Book(createBookDto, authors, publishers);
      await em.persistAndFlush(book);
      return book;
    })
  }

  async findAll(query: Object = {}): Promise<Book[]> {
    return this.em.find(Book, query, { populate: ['authors', 'publishers'] })
  }

  async findOne(id: string): Promise {
    return this.em.findOneOrFail(Book, id, { populate: ['authors', 'publishers'] })
  }

  async update(id: string, updateBookDto: UpdateBookDto): Promise {
    return await this.em.transactional(async (em) => {
      const authors = await em.find(Author, { id: { $in: updateBookDto.authors ?? [] } })
      if ((authors?.length ?? 0) !== (updateBookDto?.authors?.length ?? 0)) throw new NotFoundException('author not found')
      const publishers = await em.find(Publisher, { id: { $in: updateBookDto.publishers ?? [] } })
      if ((publishers?.length ?? 0) !== (updateBookDto?.publishers?.length ?? 0)) throw new NotFoundException('publisher not found')
      const book = await em.findOneOrFail(Book, { id })
      Object.assign(book, {
        ...updateBookDto,
        authors: updateBookDto.authors === null ? book.authors : authors,
        publishers: updateBookDto.publishers === null ? book.publishers : publishers
      })
      await em.persistAndFlush(book)
      return book
    })
  }

  async remove(id: string): Promise {
    const book = await this.em.findOneOrFail(Book, id)
    await this.em.removeAndFlush(book)
    return book
  }
}

Initial Migration

After all entities have been defined, you are ready to create an initial migration. This migration creates database tables for the entities plus an additional table to keep track of the migrations.

Install the TypeORM command line tool:

npm i -D @mikro-orm/cli

Create a configuration file mikro-orm.config.ts. This file is used in the command line interface and can be different to the configuration in your code. This is especially useful, when you run a real SQL database, such as MariaDB, then just specify localhost:3306, a dummy password, and run a database from Docker. For SQLite configuration can be:

import { TsMorphMetadataProvider } from "@mikro-orm/reflection"

export default {
    entities: [
        'dist/src/**/entities/*.entity.js'
    ],
    entitiesTs: [
        'src/**/entities/*.entity.ts'
    ],
    type: 'sqlite',
    dbName: 'test-db.sqlite3',
    metadataProvider: TsMorphMetadataProvider,
    migrations: {
        path: 'migrations',
    }
}

Tell MikroOrm to use a TypeScript file in `package.json` add:

  "mikro-orm": {
    "useTsNode": true,
    "configPaths": [
      "./mikro-orm.config.ts"
    ]
  }

Finally create your initial migration:

npx mikro-orm migration:create --initial

A JSON schema file and a new migrations file is created in path migrations. Check them in the editor.

Please be aware, that you need to to create a new migration, whenever you change anything in the entities. You should always check the generated result and manually fix the migration file if necessary.

As long as you haven’t released a migration to production yet, and no importatnt data is depending on it, you may remove and recreate the migration files. But never do this, when a database with production data already run this specific migration. Otherwise, you have to make sure, you don’t mess up with the different versions of your schemas.

Run

Now just run it:

npm start

Then head your browser to the API documentation and play with it:

https://localhost:4000/api

Go to «PATCH /author», klick «Try it out», set the message body to:

{
  "first_names": [
    "Marc", "Roman"
  ],
  "last_names": [
    "Wäckerlin"
  ],
  "born": "1971-05-30"
}

and execute it. You may create other authors, or even the same multiple times, since not the name, but a unique uuid identifies the data. Then execute «GET /author» to verify that your authors have been stored. Feel free to also test the other functions.

comments title