MikroORM

MikroORMは「TypeScriptで構築されたNode.js向けの最新ORM」として開発された、Data Mapper、Unit of Work、Identity Mapパターンに基づく高性能ORM。MongoDB、MySQL、MariaDB、PostgreSQL、SQLiteなど幅広いデータベースをサポートし、厳密な型安全性とテスタビリティを重視した設計。自動マイグレーション、エンティティジェネレーター、クエリビルダーなど豊富な機能を提供し、エンタープライズ開発に最適化されています。

ORMTypeScriptNode.jsデータマッパーリポジトリパターン単体テスト

GitHub概要

mikro-orm/mikro-orm

TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, MariaDB, MS SQL Server, PostgreSQL and SQLite/libSQL databases.

スター8,427
ウォッチ49
フォーク587
作成日:2018年3月15日
言語:TypeScript
ライセンス:MIT License

トピックス

databasedatamapperentitiesentityidentity-mapjavascriptlibsqlmongodbmysqlnodejsormpostgrepostgresqlsqlsql-serversqlitesqlite3typescripttypescript-ormunit-of-work

スター履歴

mikro-orm/mikro-orm Star History
データ取得日時: 2025/8/13 01:43

ライブラリ

MikroORM

概要

MikroORMは「TypeScriptで構築されたNode.js向けの最新ORM」として開発された、Data Mapper、Unit of Work、Identity Mapパターンに基づく高性能ORM。MongoDB、MySQL、MariaDB、PostgreSQL、SQLiteなど幅広いデータベースをサポートし、厳密な型安全性とテスタビリティを重視した設計。自動マイグレーション、エンティティジェネレーター、クエリビルダーなど豊富な機能を提供し、エンタープライズ開発に最適化されています。

詳細

MikroORM 2025年版は現代的なTypeScript開発のベストプラクティスを体現したORM実装として注目されています。Data Mapperパターンの採用により、ドメインロジックとデータアクセス層の明確な分離を実現し、保守性と拡張性に優れたアーキテクチャを構築可能。デコレーターベースのエンティティ定義、リポジトリパターンによるデータアクセス抽象化、Identity Mapによる効率的なオブジェクト管理などの特徴により、大規模アプリケーション開発に適しています。TypeScript完全対応、充実したテストサポート、豊富なリレーションシップ管理機能を提供します。

主な特徴

  • Data Mapperパターン: ドメインロジックとデータアクセス層の完全分離
  • TypeScript完全対応: 厳密な型安全性とIDEサポート
  • マルチデータベース対応: MongoDB、MySQL、PostgreSQL、SQLite等
  • リポジトリパターン: 統一されたデータアクセスインターフェース
  • 自動マイグレーション: スキーマ変更の自動管理
  • 高パフォーマンス: Identity MapとUnit of Workによる最適化

メリット・デメリット

メリット

  • TypeScript完全対応による優れた開発体験と型安全性
  • Data Mapperパターンによる疎結合で保守性の高いアーキテクチャ
  • 包括的なテストサポートと高いテスタビリティ
  • 豊富なリレーションシップ管理とクエリ最適化機能
  • 自動マイグレーションと強力なスキーマ管理
  • 活発なコミュニティとドキュメントの充実

デメリット

  • Active Recordパターンに慣れた開発者には学習コストが高い
  • 小規模プロジェクトには過度に複雑になる可能性
  • パフォーマンスチューニングには高度な知識が必要
  • MongoDB使用時はリレーショナルORM機能が一部制限
  • 他のTypeScript ORMと比較して設定項目が多い
  • デバッグ時にData Mapperの抽象化により問題箇所の特定が困難

参考ページ

書き方の例

インストールと基本セットアップ

# Core パッケージのインストール
npm install @mikro-orm/core @mikro-orm/cli

# データベース固有ドライバーのインストール
# PostgreSQL
npm install @mikro-orm/postgresql

# MySQL
npm install @mikro-orm/mysql

# SQLite
npm install @mikro-orm/sqlite

# MongoDB
npm install @mikro-orm/mongodb

# リフレクションメタデータサポート
npm install reflect-metadata

エンティティ定義と基本操作

import { Entity, PrimaryKey, Property, ManyToOne, OneToMany, Collection } from '@mikro-orm/core';

@Entity()
export class User {
  @PrimaryKey()
  id!: number;

  @Property()
  name!: string;

  @Property({ unique: true })
  email!: string;

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

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

  @OneToMany(() => Article, article => article.author)
  articles = new Collection<Article>(this);
}

@Entity()
export class Article {
  @PrimaryKey()
  id!: number;

  @Property()
  title!: string;

  @Property({ type: 'text' })
  content!: string;

  @Property()
  publishedAt?: Date;

  @ManyToOne(() => User)
  author!: User;
}

// MikroORM設定
import { MikroORM } from '@mikro-orm/core';
import { PostgreSqlDriver } from '@mikro-orm/postgresql';

const orm = await MikroORM.init({
  entities: [User, Article],
  driver: PostgreSqlDriver,
  dbName: 'my_database',
  user: 'username',
  password: 'password',
  host: 'localhost',
  port: 5432,
  debug: true,
});

// 基本的なCRUD操作
const em = orm.em.fork();

// ユーザー作成
const user = new User();
user.name = '田中太郎';
user.email = '[email protected]';

await em.persist(user).flush();

// ユーザー検索
const users = await em.find(User, { name: '田中太郎' });
const userWithArticles = await em.findOneOrFail(User, 1, {
  populate: ['articles']
});

リポジトリパターンとサービス層

import { EntityRepository } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';

// カスタムリポジトリ
export class UserRepository extends EntityRepository<User> {
  async findByEmail(email: string): Promise<User | null> {
    return this.findOne({ email });
  }

  async findActiveUsers(): Promise<User[]> {
    return this.find({
      createdAt: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }
    });
  }

  async getUserWithRecentArticles(userId: number): Promise<User | null> {
    return this.findOne(userId, {
      populate: {
        articles: {
          where: {
            publishedAt: { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
          },
          orderBy: { publishedAt: 'DESC' }
        }
      }
    });
  }
}

// サービス層でのリポジトリ使用
import { Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: UserRepository,
    @InjectRepository(Article)
    private readonly articleRepository: EntityRepository<Article>
  ) {}

  async createUser(userData: { name: string; email: string }): Promise<User> {
    // 重複チェック
    const existingUser = await this.userRepository.findByEmail(userData.email);
    if (existingUser) {
      throw new Error('ユーザーは既に存在します');
    }

    const user = new User();
    user.name = userData.name;
    user.email = userData.email;

    await this.userRepository.getEntityManager().persist(user).flush();
    return user;
  }

  async getUserDashboard(userId: number) {
    const user = await this.userRepository.getUserWithRecentArticles(userId);
    if (!user) {
      throw new Error('ユーザーが見つかりません');
    }

    return {
      user: {
        id: user.id,
        name: user.name,
        email: user.email
      },
      recentArticles: user.articles.getItems().map(article => ({
        id: article.id,
        title: article.title,
        publishedAt: article.publishedAt
      }))
    };
  }
}

高度なクエリとトランザクション管理

import { QueryBuilder, LoadStrategy } from '@mikro-orm/core';

// 複雑なクエリビルダー使用例
class AdvancedUserService {
  constructor(private em: EntityManager) {}

  async getPopularAuthors(limit: number = 10) {
    const qb = this.em.createQueryBuilder(User, 'u')
      .select(['u.*', 'COUNT(a.id) as article_count'])
      .leftJoin('u.articles', 'a')
      .where('a.publishedAt > ?', [new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)])
      .groupBy('u.id')
      .orderBy({ article_count: 'DESC' })
      .limit(limit);

    return await qb.getResult();
  }

  async searchArticlesWithFacets(searchTerm: string, authorId?: number) {
    const qb = this.em.createQueryBuilder(Article, 'a')
      .select('a.*')
      .leftJoinAndSelect('a.author', 'author')
      .where('a.title ILIKE ? OR a.content ILIKE ?', 
             [`%${searchTerm}%`, `%${searchTerm}%`]);

    if (authorId) {
      qb.andWhere('a.author = ?', [authorId]);
    }

    return await qb.getResult();
  }

  // トランザクション管理
  async transferArticleOwnership(fromUserId: number, toUserId: number, articleIds: number[]) {
    await this.em.transactional(async (em) => {
      // 送信者と受信者の検証
      const fromUser = await em.findOneOrFail(User, fromUserId);
      const toUser = await em.findOneOrFail(User, toUserId);

      // 記事の所有権確認と移転
      const articles = await em.find(Article, {
        id: { $in: articleIds },
        author: fromUser
      });

      if (articles.length !== articleIds.length) {
        throw new Error('指定された記事の一部が見つからないか、権限がありません');
      }

      // 所有権移転
      articles.forEach(article => {
        article.author = toUser;
      });

      // ログ記録(監査ログ)
      console.log(`記事所有権移転: ${articleIds.length}${fromUserId} -> ${toUserId}`);
    });
  }

  // バッチ操作
  async bulkUpdateArticleStatus(authorId: number, published: boolean) {
    const result = await this.em.nativeUpdate(Article, 
      { author: authorId }, 
      { publishedAt: published ? new Date() : null }
    );
    
    return { affected: result };
  }
}

マイグレーションとスキーマ管理

// mikro-orm.config.ts
import { defineConfig } from '@mikro-orm/core';
import { PostgreSqlDriver } from '@mikro-orm/postgresql';
import { Migrator } from '@mikro-orm/migrations';
import { EntityGenerator } from '@mikro-orm/entity-generator';

export default defineConfig({
  entities: ['./src/entities/**/*.ts'],
  entitiesTs: ['./src/entities/**/*.ts'],
  driver: PostgreSqlDriver,
  dbName: 'my_database',
  migrations: {
    path: './src/migrations',
    pattern: /^[\w-]+\d+\.[jt]s$/,
    transactional: true,
    allOrNothing: true,
  },
  extensions: [Migrator, EntityGenerator],
  debug: process.env.NODE_ENV !== 'production',
});

// CLIコマンド例
// マイグレーション作成
// npx mikro-orm migration:create

// マイグレーション実行
// npx mikro-orm migration:up

// スキーマ更新
// npx mikro-orm schema:update --run

// エンティティ生成(既存DBから)
// npx mikro-orm generate-entities

テスト環境での利用

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

// テスト用設定
export async function createTestORM() {
  const orm = await MikroORM.init({
    entities: [User, Article],
    driver: SqliteDriver,
    dbName: ':memory:', // インメモリDB
    allowGlobalContext: true,
  });

  await orm.schema.createSchema();
  return orm;
}

// JestテストでのORM使用例
describe('UserService', () => {
  let orm: MikroORM;
  let userService: UserService;

  beforeAll(async () => {
    orm = await createTestORM();
    userService = new UserService(orm.em.getRepository(User), orm.em.getRepository(Article));
  });

  beforeEach(async () => {
    await orm.schema.clearDatabase();
  });

  afterAll(async () => {
    await orm.close();
  });

  test('should create user successfully', async () => {
    const userData = { name: 'テストユーザー', email: '[email protected]' };
    const user = await userService.createUser(userData);

    expect(user.id).toBeDefined();
    expect(user.name).toBe(userData.name);
    expect(user.email).toBe(userData.email);
  });

  test('should throw error for duplicate email', async () => {
    const userData = { name: 'ユーザー1', email: '[email protected]' };
    await userService.createUser(userData);

    await expect(userService.createUser(userData))
      .rejects.toThrow('ユーザーは既に存在します');
  });
});

パフォーマンス最適化とベストプラクティス

// N+1問題の回避
class OptimizedQueries {
  constructor(private em: EntityManager) {}

  // 悪い例: N+1問題が発生
  async getBadUserArticles() {
    const users = await this.em.find(User, {});
    const results = [];
    
    for (const user of users) {
      const articles = await this.em.find(Article, { author: user }); // N+1!
      results.push({ user, articles });
    }
    
    return results;
  }

  // 良い例: 適切なpopulateを使用
  async getGoodUserArticles() {
    return await this.em.find(User, {}, {
      populate: ['articles'],
      strategy: LoadStrategy.JOINED // JOINクエリを使用
    });
  }

  // バッチローディング
  async getUsersWithArticleCount() {
    const qb = this.em.createQueryBuilder(User, 'u')
      .select(['u.*', 'COUNT(a.id) as article_count'])
      .leftJoin('u.articles', 'a')
      .groupBy('u.id');

    return await qb.getResultAndCount();
  }

  // 部分的ローディング
  async getUserSummaries() {
    return await this.em.find(User, {}, {
      fields: ['id', 'name', 'email'], // 必要なフィールドのみ
    });
  }

  // キャッシング戦略
  private cache = new Map<string, any>();

  async getCachedPopularArticles(ttl: number = 300000) { // 5分
    const cacheKey = 'popular_articles';
    const cached = this.cache.get(cacheKey);
    
    if (cached && Date.now() - cached.timestamp < ttl) {
      return cached.data;
    }

    const data = await this.em.find(Article, 
      { publishedAt: { $ne: null } },
      { 
        orderBy: { publishedAt: 'DESC' },
        limit: 20,
        populate: ['author']
      }
    );

    this.cache.set(cacheKey, { data, timestamp: Date.now() });
    return data;
  }
}