Previously, I briefly explained what ORMs are, why to use them, and I wrote about Sequelize. In this blog post I'm gonna share something about Mikro-ORM, and I'm going to compare it with Sequelize.
Mikro-ORM is a TypeScript ORM for Node.js applications. It comes with many features:
- multiple ways to define entities,
- entity generator from existing database,
- implicit transactions,
which makes it a strong tool. You can choose to work with SQL or No-SQL databases, but in this blog I'm gonna be using PostgreSQL and the database schema that I described in my previous blog post.
Installing Mikro-ORM for PostgreSQL
npm i @mikro-orm/core
npm i @mikro-orm/postgresql
Set up for mikro-orm/cli
Define path to a config file in package.json
"mikro-orm": {
"configPaths": [
"./db.config.js"
]
},
Connection
I used types where possible, because Mikro-ORM type support is great. For database connection I specified the type of db driver I'm gonna be using and the properties needed for initialization.
You can specify some other additional properties like replica databases, logger, debug logs, and more, which will help you with development or debugging in case you have some issues.
import type { PostgreSqlDriver } from '@mikro-orm/postgresql';
const orm = MikroORM.init({
entitiesTs: ['src/app/database/**.*ts'],
dbName: 'my-db-name',
type: 'postgresql',
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'postgres',
baseDir: process.cwd(),
replicas: [
{ user: 'read-user-1', host: 'read-1.db.example.com', port: 5433 },
{ user: 'read-user-2', host: 'read-2.db.example.com', port: 5434 },
{ user: 'read-user-3', host: 'read-3.db.example.com', port: 5435 },
],
logger: (message: string) => myLogger.info(message),
debug: true, _// or provide array like ['query', 'query-params']_
})
Entity
There are two approaches to modeling entities:
- using classes and decorators
- using classes with entity schema
I went with decorated classes, because for me it’s more straightforward.
Going with the first approach, you need to use decorators and for that you need to set up the tsconfig.json file.
...
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true
...
Entity (class with decorators)
With decorated class entities, defining relations worked correctly for me. This is how schemas are defined in typescript with Mikro-ORM:
import { Collection, Entity, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'
import { TripDriver } from './TripDriver.entity'
@Entity({ tableName: 'drivers' })
export class Driver {
@PrimaryKey()
id!: number
@Property({ nullable: false, unique: true })
username!: string
@Property({ nullable: true })
firstname?: string
@Property({ nullable: true })
surname?: string
@Property({ nullable: true, onCreate: () => new Date() })
created_at?: Date
@Property({ nullable: true, onCreate: () => new Date(), onUpdate: () => new Date() })
updated_at?: Date
@Property({ nullable: true })
deleted_at?: Date
@OneToMany('tripDriver','driver')
tripDriver = new Collection(this)
}
On every column you need to use the decorator @Property and define constraints, hooks (onCreate, onUpdate, ...), and the name of the column.
For the entity, you can specify a relation. In my case, I defined the relation @ManyToOne, a reference to the drivers entity on tripDrivers entity:
@ManyToOne(() => Driver, { wrappedReference: true })
driver: IdentifiedReference'id'>
constructor(driver: Driver) {
this.driver = Reference.create(driver)
}
wrappedReference
wraps an entity in a Reference
wrapper which provides better type-safety. It maps the reference to the referenced entity object. However, you have to create a reference to the entity in the entity constructor.
When you populate the referenced entity, you can call Entity.getEntity()
on a referenced entity, which is a synchronous getter that will first check whether the wrapped entity is initialized and if not, it will throw an error.
const driverToTrips = tripDriverRepo.findAll({ populate: ['driver'] })
driverToTrips.map(trips => {
const driver = trips.driver.getEntity()
})
IdentifiedReference
directly adds a primary key to the Reference
instance.
A simpler way to reference an entity is to define the entity itself when you define the @ManyToOne
relation. Without wrappedReference
, typescript would think that the entity is always loaded. Without wrappedReference
:
@ManyToOne()
driver!: Driver
const driverToTrips = tripDriverRepo.findAll({ populate: ['driver'] })
driverToTrips.map(trips => {
const driver = trips.driver
})
@OneToMany, a reference to the tripDrivers entity on Driver entity:
@OneToMany('TripDriver','driver')
tripDriver = new Collection(this)
CRUD operations
To perform a CRUD operation, you need to create a repository from an initialized connection and a defined entity, in my case Driver. You call a connection.em.getRepository(Driver)
on the connection with an entity and from that you get an entity repository object with CRUD operation methods.
This is a slightly different approach from Sequelize ORM where you can perform CRUD operations on the entity itself. Here you separate the entity and connection and then you connect the entity with the connection. I prefer working with data mapper APIs, because they generally do better separation of concerns. The other approach would be the active record. The differences between active record and data mapper are well described in the article available here: https://orkhan.gitbook.io/typeorm/docs/active-record-data-mapper.
One great feature is that all CRUD operations support batching, which means you can delete/update/create/read more records in one call by default.
Read
Call find
, or findOn
.
const selectQueries = (connection: MikroORM) => {
const driverRepo = connection.em.getRepository(Driver)
const selectById = (id: number) => driverRepo.findOne({ id })
const selectAllLimited = () => driverRepo.find({
$and: [{
firstname: 'Charles',
surname: 'Fourth',
}]
},{
orderBy: {
id: QueryOrder.DESC,
},
limit: 10,
})
...
If you want to use operators such as and, or, in, etc, you can specify them in the find
call in the first parameter with a dollar sign prefix, and you put operator values in an array object. You have basically the same options as you would have writing operators in a raw query. Operators which you can use with specific databases are defined in documentation.
Create
User create
, or nativeInsert
. Native means that the input is transformed into native sql query via QueryBuilder. The same for update and delete methods. That means it doesn't hydrate results to entities and it doesn't trigger the onCreate lifecycle hooks.
const driverRepo = connection.em.getRepository(Driver)
const insertDriver = (surname: string) =>
driverRepo.nativeInsert({ username: randomUUID(), surname })
Update
const driverRepo = connection.em.getRepository(Driver)
const updateById = (id: number) =>
driverRepo.nativeUpdate({ id }, { surname: 'Vomáčka' })
Delete
const driverRepo = connection.em.getRepository(Driver)
const deleteById = (id: number) =>
driverRepo.nativeDelete({ id })
Relations
I described how to define relations on entities before. When you want to map a referenced entity you call findAll
and provide {populate: [...]}
parameter to relate one entity to another.
driverRepo.findAll({ populate: ['tripDriver'] })
tripDriverRepo.findAll({ populate: ['driver'] })
Migrations
Migrations are a strong side of Mikro-ORM. However, you need to do a little bit of a set up and I’m gonna show you how to do it.
Install dependencies for running migrations
npm i @mikro-orm/cli @mikro-orm/core @mikro-orm/migrations @mikro-orm/postgresql pg
Create a config.js file
const options = {
...
migrations: {
tableName: 'migrations-mikro-orm',
path: 'migrations',
transactional: true,
allOrNothing: true,
emit: 'js',
safe: true,
disableForeignKeys: false,
dropTables: false,
fileName: timestamp => `${timestamp}-my-migration`,
snapshot: true,
},
}
module.exports = options
Initial migration
Initial migration is a specific migration operation and it's used only when you've already created entities and relations among them in your code. By running initial migration, a migration file is created with generated code that will create tables based on defined entities. After running the created migration file from initial migration, it creates tables in your database.
npx mikro-orm migration:create --initial
Migrate up to the latest version
npx mikro-orm migration:up
Migrate only specific migrations
npx mikro-orm migration:up --from 2019101911 --to 2019102117
Migrate one migration
npx mikro-orm migration:up --only 2019101923
Rollback migration
npx mikro-orm migration:down
List all migrations
npx mikro-orm migration:list
List pending migrations
npx mikro-orm migration:pending
Drop the database and migrate up to the latest version
mikro-orm migration:fresh
Seeds
Seeds are a strong side of Mikro-ORM as well. The greatest thing is you can run seeds from an sql file directly. You only need to specify a path to a seed file.
npx mikro-orm database:import ../seeds.sql
Summary
Mikro-ORM is a very powerful tool, it provides a lot of regular and advanced features. The documentation is well written, although for a basic use, it's maybe too detailed, and it could be tricky to find what you want at first glance.
With entity modeling, I like that you can choose between decorated classes and entity schema. If I compare defining entities with Sequelize, I would say it uses the same approach, but offers a little bit more options.
When it comes to migrations and seeds, Mikro-ORM is the right tool for that and honestly for me is a winner so far. Apart from basic migration functionality, it provides you with some options that other ORMs don't have, such as generating database tables schema from entities in code or the other way of generating entities from database tables. I only missed describing configuration for migrations, because I had to do a little bit of investigation on how to set up the configuration file for migrations properly.
That was a short description of how to use Mikro-ORM, the key features of it and its pros and cons. The next post will be about Zapatos.