MikroORM
MikroORM is a TypeScript ORM for Node.js that supports multiple databases such as PostgreSQL, MySQL, MariaDB, SQLite, and MongoDB. It is based on the Data Mapper, Unit of Work, and Identity Map patterns, aiming to provide a powerful and easy-to-use database toolset.
@gqloom/mikro-orm
provides integration between GQLoom and MikroORM:
- Use MikroORM Entities as Silks;
- Use resolver factories to quickly generate CRUD operations from MikroORM.
Installation
Please refer to MikroORM's Getting Started guide to install MikroORM and the corresponding database drivers.
After installing MikroORM, install @gqloom/mikro-orm
:
npm i @gqloom/core @gqloom/mikro-orm
pnpm add @gqloom/core @gqloom/mikro-orm
yarn add @gqloom/core @gqloom/mikro-orm
bun add @gqloom/core @gqloom/mikro-orm
Using Silks
By simply wrapping MikroORM Entities with mikroSilk
, we can easily use them as Silks.
export const User = mikroSilk(UserEntity)
export const Post = mikroSilk(PostEntity)
import { mikroSilk } from "@gqloom/mikro-orm"
import { type InferEntity, defineEntity } from "@mikro-orm/core"
const UserEntity = defineEntity({
name: "User",
properties: (p) => ({
id: p.integer().primary().autoincrement(),
createdAt: p.datetime().onCreate(() => new Date()),
email: p.string(),
name: p.string(),
role: p.string().$type<"admin" | "user">().default("user"),
posts: () => p.oneToMany(PostEntity).mappedBy("author"),
}),
})
export interface IUser extends InferEntity<typeof UserEntity> {}
const PostEntity = defineEntity({
name: "Post",
properties: (p) => ({
id: p.integer().primary().autoincrement(),
createdAt: p.datetime().onCreate(() => new Date()),
updatedAt: p
.datetime()
.onCreate(() => new Date())
.onUpdate(() => new Date()),
published: p.boolean().default(false),
title: p.string(),
author: () => p.manyToOne(UserEntity),
}),
})
export interface IPost extends InferEntity<typeof PostEntity> {}
export const User = mikroSilk(UserEntity)
export const Post = mikroSilk(PostEntity)
Before using them in resolvers, we need to initialize MikroORM and provide a request-scoped Entity Manager.
import type { Middleware } from "@gqloom/core"
import { createMemoization, useResolvingFields } from "@gqloom/core/context"
import { MikroORM } from "@mikro-orm/libsql"
import { Post, User } from "./entities"
export let orm: MikroORM
export const ormPromise = MikroORM.init({
entities: [User, Post],
dbName: ":memory:",
debug: true,
}).then(async (o) => {
orm = o
await orm.getSchemaGenerator().updateSchema()
})
export const useEm = createMemoization(() => orm.em.fork())
export const useSelectedFields = () => {
return Array.from(useResolvingFields()?.selectedFields ?? ["*"]) as []
}
export const flusher: Middleware = async ({ next }) => {
const result = await next()
await useEm().flush()
return result
}
Now we can use them in resolvers:
import { field, mutation, query, resolver } from "@gqloom/core"
import * as v from "valibot"
import { Post, User } from "./entities"
import { flusher, useEm, useSelectedFields } from "./provider"
export const userResolver = resolver.of(User, {
user: query(User.nullable())
.input({ id: v.number() })
.resolve(async ({ id }) => {
const user = await useEm().findOne(
User,
{ id },
{ fields: useSelectedFields() }
)
return user
}),
users: query(User.list()).resolve(() => {
return useEm().findAll(User, { fields: useSelectedFields() })
}),
createUser: mutation(User)
.input({
data: v.object({
name: v.string(),
email: v.string(),
}),
})
.use(flusher)
.resolve(async ({ data }) => {
const user = useEm().create(User, data)
useEm().persist(user)
return user
}),
posts: field(Post.list())
.derivedFrom("id")
.resolve((user) => {
return useEm().find(
Post,
{ author: user.id },
{ fields: useSelectedFields() }
)
}),
})
As shown in the code above, we can directly use MikroORM entities wrapped with mikroSilk
in the resolver
. Here, we use User
as the parent type for resolver.of
, and define two queries, user
and users
, as well as a createUser
mutation.
All database operations are performed through the request-scoped Entity Manager obtained via useEm()
.
For mutation operations, we use a flusher
middleware that automatically calls em.flush()
to persist changes to the database after a successful mutation.
We also use the useSelectedFields()
function to ensure that only the fields requested in the GraphQL query are selected, which helps optimize database query performance. This function requires enabling context.
Derived Fields
Adding derived fields to database entities is very simple:
import { field, resolver } from "@gqloom/core"
import * as v from "valibot"
import { type IUser, User } from "./entities"
export const userResolver = resolver.of(User, {
display: field(v.string())
.derivedFrom("name", "email")
.resolve((user) => {
return `${user.name} <${user.email}>`
}),
})
Note: Derived fields need to use the derivedFrom
method to declare the dependent columns, so that the useSelectedFields
method can correctly select the required columns.
Hiding Fields
@gqloom/mikro-orm
exposes all fields by default. If you want to hide certain fields, such as a password, you can use field.hidden
:
import { field, resolver } from "@gqloom/core"
import { User } from "./entities"
export const userResolver = resolver.of(User, {
password: field.hidden,
})
In the code above, we hide the password
field, which means it will not appear in the generated GraphQL Schema.
Resolver Factory
@gqloom/mikro-orm
provides MikroResolverFactory
to help you create resolver factories. With resolver factories, you can quickly define common queries, mutations, and fields. The resolver factory also provides preset input types for common operations, which can greatly reduce boilerplate code and is very useful for rapid development.
import { MikroResolverFactory } from "@gqloom/mikro-orm"
import { Post, User } from "./entities"
import { useEm } from "./provider"
export const userResolverFactory = new MikroResolverFactory(User, useEm)
export const postResolverFactory = new MikroResolverFactory(Post, useEm)
In the code above, we created resolver factories for the User
and Post
models. MikroResolverFactory
accepts two arguments: the first is the entity as a silk, and the second is a function that returns an EntityManager
instance.
Relation Fields
The resolver factory provides referenceField
and collectionField
methods to define relation fields:
import { field, query, resolver } from "@gqloom/core"
import { MikroResolverFactory } from "@gqloom/mikro-orm"
import * as v from "valibot"
import { Post, User } from "./entities"
import { useEm } from "./provider"
export const userResolverFactory = new MikroResolverFactory(User, useEm)
export const postResolverFactory = new MikroResolverFactory(Post, useEm)
export const userResolver = resolver.of(User, {
user: userResolverFactory.findOneQuery(),
posts: userResolverFactory.collectionField('posts'),
})
export const postResolver = resolver.of(Post, {
author: postResolverFactory.referenceField('author'),
})
In the code above, we use userResolverFactory.collectionField('posts')
and postResolverFactory.referenceField('author')
to define relation fields. collectionField
is used for one-to-many
and many-to-many
relations, while referenceField
is used for many-to-one
and one-to-one
relations.
Queries
The resolver factory comes with preset common queries:
You can use them directly:
import { query, resolver } from "@gqloom/core"
import { MikroResolverFactory } from "@gqloom/mikro-orm"
import * as v from "valibot"
import { User } from "./entities"
import { useEm } from "./provider"
export const userResolverFactory = new MikroResolverFactory(User, useEm)
export const userResolver = resolver.of(User, {
user: userResolverFactory.findOneQuery(),
posts: userResolverFactory.collectionField('posts'),
})
In the code above, we use userResolverFactory.findOneQuery()
to define the user
query. The resolver factory will automatically create the input type and resolver function.
Mutations
The resolver factory comes with preset common mutations:
- createMutation
- insertMutation
- insertManyMutation
- deleteMutation
- updateMutation
- upsertMutation
- upsertManyMutation
You can use them directly:
import { resolver } from "@gqloom/core"
import { MikroResolverFactory } from "@gqloom/mikro-orm"
import { Post } from "./entities"
import { useEm } from "./provider"
export const postResolverFactory = new MikroResolverFactory(Post, useEm)
export const postResolver = resolver.of(Post, {
createPost: postResolverFactory.createMutation(),
author: postResolverFactory.referenceField('author'),
})
In the code above, we use postResolverFactory.createMutation()
to define the createPost
mutation. The factory will automatically create the input type and resolver function.
Custom Input Fields
The default preset inputs of the resolver factory are configurable. By passing the input
option during the construction of MikroResolverFactory
, you can configure the input validation and display behavior for each field:
import { field } from "@gqloom/core"
import { MikroResolverFactory } from "@gqloom/mikro-orm"
import * as v from "valibot"
const userFactory = new MikroResolverFactory(User, {
getEntityManager: useEm,
input: {
email: v.pipe(v.string(), v.email()), // Validate email format
password: {
filters: field.hidden, // Hide this field in query filters
create: v.pipe(v.string(), v.minLength(6)), // Validate minimum length of 6 characters on creation
update: v.pipe(v.string(), v.minLength(6)), // Validate minimum length of 6 characters on update
},
},
})
Custom Input Object
The preset queries and mutations in the resolver factory support custom inputs. You can define the input type through the input
option:
import { resolver } from "@gqloom/core"
import { MikroResolverFactory } from "@gqloom/mikro-orm"
import * as v from "valibot"
import { User } from "./entities"
import { useEm } from "./provider"
export const userResolverFactory = new MikroResolverFactory(User, useEm)
export const userResolver = resolver.of(User, {
user: userResolverFactory.findOneQuery({
input: v.pipe(
v.object({ id: v.number() }),
v.transform(({ id }) => ({ where: { id } }))
)
}),
})
In the code above, we use valibot
to define the input type. v.object({ id: v.number() })
defines the type of the input object, and v.transform(({ id }) => ({ where: { id } }))
transforms the input argument into MikroORM's query parameters.
Adding Middleware
The preset queries, mutations, and fields in the resolver factory support adding middleware. You can add middleware using the use
method:
import { resolver } from "@gqloom/core"
import { createMemoization } from "@gqloom/core/context"
import { MikroResolverFactory } from "@gqloom/mikro-orm"
import { GraphQLError } from "graphql"
import { Post } from "./entities"
import { useEm } from "./provider"
const postResolverFactory = new MikroResolverFactory(Post, useEm)
const useAuthedUser = createMemoization(async () => ({ id: 1, name: "test" }))
const postResolver = resolver.of(Post, {
createPost: postResolverFactory.createMutation().use(async (next) => {
const user = await useAuthedUser()
if (user == null) throw new GraphQLError("Please login first")
return next()
}),
})
In the code above, we use the use
method to add a middleware. useAuthedUser()
is a custom function to get the currently logged-in user. If the user is not logged in, it throws an error; otherwise, it calls next()
to proceed.
Complete Resolver
You can create a complete Resolver directly from the resolver factory:
import { MikroResolverFactory } from "@gqloom/mikro-orm"
import { User } from "./entities"
import { useEm } from "./provider"
export const userResolverFactory = new MikroResolverFactory(User, useEm)
// Readonly Resolver
const userQueriesResolver = userResolverFactory.queriesResolver()
// Full Resolver
const userResolver = userResolverFactory.resolver()
There are two functions for creating a Resolver:
usersResolverFactory.queriesResolver()
: Creates a Resolver containing only queries and relation fields.usersResolverFactory.resolver()
: Creates a complete Resolver containing all queries, mutations, and relation fields.
Custom Type Mapping
To accommodate more MikroORM types, we can extend GQLoom to add more type mappings.
First, we use MikroWeaver.config
to define the type mapping configuration. Here we import GraphQLDateTime
from graphql-scalars, and when we encounter a datetime
type, we map it to the corresponding GraphQL scalar.
import { MikroWeaver } from "@gqloom/mikro-orm"
import { GraphQLDateTime } from "graphql-scalars"
export const mikroWeaverConfig = MikroWeaver.config({
presetGraphQLType: (property) => {
if (property.type === "datetime") {
return GraphQLDateTime
}
},
})
Pass the configuration to the weave function when weaving the GraphQL Schema:
export const schema = weave(mikroWeaverConfig, userResolver, postResolver)
Default Type Mapping
The following table lists the default mapping between MikroORM types and GraphQL types in GQLoom:
MikroORM Type | GraphQL Type |
---|---|
(primary) | GraphQLID |
string | GraphQLString |
number | GraphQLFloat |
float | GraphQLFloat |
double | GraphQLFloat |
decimal | GraphQLFloat |
integer | GraphQLInt |
smallint | GraphQLInt |
mediumint | GraphQLInt |
tinyint | GraphQLInt |
bigint | GraphQLInt |
boolean | GraphQLBoolean |
(other) | GraphQLString |