NestJS + MikroORM
Views: 633
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:
- an
id
of typeuuid
as primary key - a creation date in
createdAt
- 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:
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.