CLOVER🍀

That was when it all began.

Node.js × TypeScriptのORM、TypeORMをMySQLで試す

これは、なにをしたくて書いたもの?

Node.js+TypeScriptのORMは、このあたりが有名みたいです。

Prismaは前に試してみました。

Node.js × TypeScriptのORM、PrismaをMySQLで試す - CLOVER🍀

今回はTypeORMを試してみたいと思います。

TypeORM

TypeORMは、TypeScriptおよびJavaScriptで使用できるORMとされています。

TypeORM - Amazing ORM for TypeScript and JavaScript (ES7, ES6, ES5). Supports MySQL, PostgreSQL, MariaDB, SQLite, MS SQL Server, Oracle, WebSQL databases. Works in NodeJS, Browser, Ionic, Cordova and Electron platforms.

GitHub - typeorm/typeorm: ORM for TypeScript and JavaScript. Supports MySQL, PostgreSQL, MariaDB, SQLite, MS SQL Server, Oracle, SAP Hana, WebSQL databases. Works in NodeJS, Browser, Ionic, Cordova and Electron platforms.

Node.jsやブラウザ上など、様々なプラットフォームをサポートしているともされています。

TypeORM is an ORM that can run in NodeJS, Browser, Cordova, PhoneGap, Ionic, React Native, NativeScript, Expo, and Electron platforms

詳細は、こちら。

Supported platforms

スタイルとしては、Active RecordとData Mapperの2つのパターンをサポートしています。

TypeORM supports both Active Record and Data Mapper patterns, unlike all other JavaScript ORMs currently in existence, which means you can write high quality, loosely coupled, scalable, maintainable applications the most productive way.

Getting Startedに載っているコード例を見ると、すごくJPAな感じがします…。

import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";

@Entity()
export class User {

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    firstName: string;

    @Column()
    lastName: string;

    @Column()
    age: number;

}

実際、Hibernateなどの影響は受けているようです。

TypeORM is highly influenced by other ORMs, such as Hibernate, Doctrine and Entity Framework.

機能については、こちら。

Features

  • Active Record、Data Mapperのサポート(選択)
  • エンティティとカラムを扱うことができる
  • データベースに固有のカラムの型を扱うことができる
  • Entity Manager
  • リポジトリーおよびカスタムリポジトリーを扱える
  • クリーンなORM
  • 関連(Associations/Relations)
  • EagerおよびLazyなリレーション
  • 一方向、双方向、自己参照のリレーション
  • 他重継承パターンのサポート
  • カスケード
  • インデックス
  • トランザクション
  • マイグレーションおよびマイグレーションの自動生成
  • コネクションプール
  • レプリケーション
  • 複数のデータベース接続のサポート
  • 複数の種類のデータベースを扱える
  • クロスデータベース、クロススキーマでのクエリーを扱える
  • 強力なQueryBuilder
  • 左内部結合
  • 結合を使用したクエリーのページング
  • クエリーキャッシュ
  • 結果のストリーミング
  • ロギング
  • リスナーおよびサブスクリプション(フック)
  • クロージャーテーブルパターンのサポート
  • モデルまたは個別の設定ファイルによるスキーマ宣言
  • JSONXMLYAMLenv形式での接続設定
  • MySQLMariaDB、Postgres、CockroachDB、SQLiteMicrosoft SQL ServerOracle、SAP Hana、sql.jsをサポート
  • MongoDBをサポート
  • Node.js、ブラウザ、Ionic、Cordova、React Native、NativeScript、Expo、Electronをサポート
  • TypeScriptおよびJavaScriptをサポート
  • ESMとCommonJSをサポート
  • CLI

いろいろあるようですが、今回は以下の条件でGetting Startedに沿って進めてみたいと思います。

  • TypeScriptを使用
  • Data Mapperを使用
  • データベースはMySQLを使用
  • テストコードで動作確認

環境

今回の環境は、こちらです。

$ node --version
v16.14.0


$ npm --version
8.3.1

使用するMySQLはこちらで、IPアドレスは172.17.0.2とします。

$ mysql --version
mysql  Ver 8.0.28 for Linux on x86_64 (MySQL Community Server - GPL)

TypeORMをインストールする

TypeORMのインストールは、ふつうにライブラリを追加していく方法と、TypeORMの提供するCLIを使ってセットアップする方法が
あるようです。

Installation

Quick Start

CLIを使った場合は、こんな感じのディレクトリツリーが生成されます(MyProjectは、typeorm init時に指定したプロジェクト名)。

MyProject
├── src              // place of your TypeScript code
│   ├── entity       // place where your entities (database models) are stored
│   │   └── User.ts  // sample entity
│   ├── migration    // place where your migrations are stored
│   └── index.ts     // start point of your application
├── .gitignore       // standard gitignore file
├── ormconfig.json   // ORM and database connection configuration
├── package.json     // node module dependencies
├── README.md        // simple readme file
└── tsconfig.json    // TypeScript compiler options

今回は、ふつうにライブライブラリを足していく方法でやってみたいと思います。

まずは、TypeScriptプロジェクトのセットアップ。

$ npm init -y
$ npm i -D typescript
$ npm i -D -E prettier
$ npm i -D jest @types/jest ts-jest
$ npx ts-jest config:init
$ mkdir src test

ここに、TypeORMをインストールします。

$ npm i typeorm
$ npm i reflect-metadata

reflect-metadataは、トップレベルのスクリプトimportしておくもののようです。こちらを使うと、TypeScriptのデコレーター等に関する
メタデータを扱うリフレクションAPIライブラリのようです。

GitHub - rbuckton/reflect-metadata: Prototype for a Metadata Reflection API for ECMAScript

Node.jsの型情報をインストール。

$ npm i -D @types/node@v16

ドライバーは、mysql2を使用することにします。

$ npm i mysql2

この時点での依存関係。

  "devDependencies": {
    "@types/jest": "^27.4.1",
    "@types/node": "^16.11.26",
    "jest": "^27.5.1",
    "prettier": "2.5.1",
    "ts-jest": "^27.1.3",
    "typescript": "^4.6.2"
  },
  "dependencies": {
    "mysql2": "^2.3.3",
    "reflect-metadata": "^0.1.13",
    "typeorm": "^0.2.45"
  }

設定ファイルはこちら。

ポイントはtsconfig.jsonで、以下を追加しておく必要があります。

    "emitDecoratorMetadata": true,
    "experimentalDecorators": true

また、libは少なくともes6である必要がありそうです。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "lib": ["esnext"],
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true
  },
  "include": [
    "src"
  ]
}

tsconfig.typecheck.json

{
  "extends": "./tsconfig",
  "compilerOptions": {
    "baseUrl": "./",
    "noEmit": true
  },
  "include": [
    "src", "test"
  ]
}

.prettierrc.json

{
  "singleQuote": true
}

jest.config.js

/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
};

Jestの実行にはesbuildを使いたかったところですが、emitDecoratorMetadatatrueにしてもesbuildではこちらを見てくれないので、
ts-jestを使うことにしました…。

TypeORMを使ってみる

では、TypeORMを使っていきます。

Entityを作成する

まずはEntityを作成してみましょう。Prismaの時と同じく、UserPostの2つのEntityを作ることにします。

src/entity/User.ts

import {
  Column,
  Entity,
  OneToMany,
  PrimaryColumn,
  PrimaryGeneratedColumn,
} from 'typeorm';
import { Post } from './Post';

@Entity({ name: 'user' })
export class User {
  @PrimaryGeneratedColumn()
  readonly id?: number;

  @Column({ name: 'last_name' })
  lastName: string;

  @Column({ name: 'fist_name' })
  firstName: string;

  @Column({ name: 'age' })
  age: number;

  @OneToMany(() => Post, (post) => post.user, {
    cascade: true,
  })
  posts: Post[];

  constructor(lastName: string, firstName: string, age: number, posts: Post[]) {
    this.lastName = lastName;
    this.firstName = firstName;
    this.age = age;
    this.posts = posts;
  }
}

src/entity/Post.ts

import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
import { User } from './User';

@Entity({ name: 'post' })
export class Post {
  @PrimaryColumn({ name: 'url' })
  url: string;

  @Column({ name: 'title' })
  title: string;

  @Column({name: 'user_id'})
  userId?: number;

  @ManyToOne(() => User, (user) => user.posts, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'user_id' })
  user?: User;

  constructor(url: string, title: string) {
    this.url = url;
    this.title = title;
  }
}

ひとりのユーザーが、複数の投稿ができるイメージで関連を付けています。

Entityにはプライマリーキーの指定が必要です。プライマリーキーは、ユーザーが自動採番、投稿はあらかじめ指定するようにしています。

自動採番しない方については、@PrimaryColumnを指定。

  @PrimaryColumn({ name: 'url' })
  url: string;

自動採番する方には@PrimaryGeneratedColumnを指定します。この時、プロパティに対して初期値が必要になりますが、
そのままだとコンパイルエラーになります。strictPropertyInitializationfalseにするとこれは緩和できますが、それも微妙なのでnull
許容しました…。

  @PrimaryGeneratedColumn()
  readonly id?: number;

関連で使うプロパティについても、同様に。

  @Column({name: 'user_id'})
  userId?: number;

  @ManyToOne(() => User, (user) => user.posts, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'user_id' })
  user?: User;

Entityに関する説明は、こちら。

Entities

また、リレーションについてはこちらですね。

Relations

今回はMany-to-one/One-to-manyのリレーションです。

Many-to-one / one-to-many relations

@JoinColumnは使わなくてもいいのですが、今回関連に使うカラムをコントロールするために指定しました。今回は、その他のカラムに
対応するプロパティもすべてカラム名を明示してあります。

ここまで見ているとわかりますが、TypeORMではこのようにデコレーターを使っていきます。

テストコードの雛形を作成する

次に、テストコードの雛形を作成します。

test/typeorm.test.ts

import 'reflect-metadata';
import { ConnectionOptions, createConnection } from 'typeorm';
import { Post } from '../src/entity/Post';
import { User } from '../src/entity/User';

const options: ConnectionOptions = {
  type: 'mysql',
  host: '172.17.0.2',
  port: 3306,
  username: 'kazuhira',
  password: 'password',
  database: 'practice',
  synchronize: true, // データベースにスキーマを自動反映させる(本番環境ではtrueにしないこと)
  logging: false, // コンソールに実際に発行するSQLを出力する
  entities: ['src/entity/**/*.ts'],
  migrations: ['src/migration/**/*.ts'],
  subscribers: ['src/subscriber/**/*.ts'],
};

// ここに、テストを書く

ConnectionOptionsで指定しているのは、データベースへの接続情報と、Entityなどのクラスに関する情報です。

entities等で指定した先を使って、Entityなどに定義されたデコレーターから情報を取得しようとするので、ここがうまく読めないと
後に出てくるEntityManagerがうまく動かないことになります。

なお、これのパスは、このファイルから見たパスというより実行時のパス(通常のファイル指定と同じ)という考え方で指定する必要が
ありそうです。

各意味については、こちらを参照。

Connection Options

synchronizeを今回trueにしていますが、こちらを使用するとEntityの定義内容からデータベーススキーマ(テーブル等)を自動作成します。
本番環境での利用は、データが失われる可能性があるため適用は非推奨です。あくまで開発用途ですね。

なので、今回はテストを実行するだけでテーブルなどが作成されます。

このような定義になりました。

mysql> desc user;
+-----------+--------------+------+-----+---------+----------------+
| Field     | Type         | Null | Key | Default | Extra          |
+-----------+--------------+------+-----+---------+----------------+
| id        | int          | NO   | PRI | NULL    | auto_increment |
| last_name | varchar(255) | NO   |     | NULL    |                |
| fist_name | varchar(255) | NO   |     | NULL    |                |
| age       | int          | NO   |     | NULL    |                |
+-----------+--------------+------+-----+---------+----------------+
4 rows in set (0.00 sec)

mysql> desc post;
+---------+--------------+------+-----+---------+-------+
| Field   | Type         | Null | Key | Default | Extra |
+---------+--------------+------+-----+---------+-------+
| url     | varchar(255) | NO   | PRI | NULL    |       |
| title   | varchar(255) | NO   |     | NULL    |       |
| user_id | int          | NO   | MUL | NULL    |       |
+---------+--------------+------+-----+---------+-------+
3 rows in set (0.00 sec)

loggingtrueにすると、実際に発行されるSQLを確認することができます。

データベースに接続してみる

データベースに接続してみます。

test('connect database', async () => {
  const connection = await createConnection(options);
  await connection.close();
});

createConnectionでデータベース接続を作成できます。使い終わったらクローズですね。

Working with Connection

データを登録する

データを登録してみます。データの登録には、EntityManagerを使用します。

What is EntityManager

EntityManager API

EntityManagerとは、Entityに対する操作を提供するクラスです。

こちらとトランザクションを使って、データを登録。

test('insert data', async () => {
  const connection = await createConnection(options);
  try {
    const entityManager = connection.manager;

    const posts = [
      new Post(
        'https://kazuhira-r.hatenablog.com/entry/2021/10/30/222106',
        'はじめてのTypeScript(+TypeScript ESLint、Prettier、Emacs lsp-mode)'
      ),
      new Post(
        'https://kazuhira-r.hatenablog.com/entry/2021/10/31/173852',
        'JestでTypeScriptのテストを書く'
      ),
      new Post(
        'https://kazuhira-r.hatenablog.com/entry/2021/11/20/184345',
        'TypeScriptでExpress'
      ),
    ];

    const users = [
      new User('フグ田', 'サザエ', 24, []),
      new User('フグ田', 'マスオ', 28, []),
      new User('磯野', 'カツオ', 11, [posts[0], posts[1]]),
      new User('磯野', 'ワカメ', 9, [posts[2]]),
      new User('フグ田', 'タラオ', 3, []),
    ];

    await entityManager.transaction(async (em) => await em.save(users));
  } catch (e) {
    console.log('hoge');
    console.log(e);
  } finally {
    await connection.close();
  }
});

UserとPostには関連を設定しているので、Userを登録すると合わせて関連しているPostも登録してくれます。

更新は、トランザクション内で行っています。トランザクションは、EntityManager#transaction内で行います。

    await entityManager.transaction(async (em) => await em.save(users));

この時、EntityManager#transactionで渡されるEntityManagerを使ってトランザクション内の処理を行う必要があります。

Transactions

EntityManager#transactionの呼び出しが終わった時には、トランザクションは完了しています。

検索してみる

登録したデータを検索してみましょう。これもEntityManagerを使って行います。

test('find simply', async () => {
  const connection = await createConnection(options);

  try {
    const entityManager = connection.manager;

    // count
    expect(await entityManager.count(Post)).toBe(3);
    expect(await entityManager.count(User)).toBe(5);

    // findOne
    const katsuo = await entityManager.findOne(User, { firstName: 'カツオ' });
    if (katsuo) {
      expect(katsuo.id).not.toBeUndefined();
      expect(katsuo.id).not.toBeNull();
      expect(katsuo.lastName).toBe('磯野');
      expect(katsuo.firstName).toBe('カツオ');
      expect(katsuo.age).toBe(11);
      expect(katsuo.posts).toBeUndefined(); // undefined
    } else {
      throw new Error('test fail');
    }

    // repository and find, order by
    const userRepository = entityManager.getRepository(User);
    const fugutaFamily = await userRepository.find({
      where: {
        lastName: 'フグ田',
      },
      order: {
        age: 'DESC',
      },
    });

    expect(fugutaFamily).toHaveLength(3);
    expect(fugutaFamily[0].firstName).toBe('マスオ');
    expect(fugutaFamily[0].age).toBe(28);
    expect(fugutaFamily[1].firstName).toBe('サザエ');
    expect(fugutaFamily[1].age).toBe(24);
    expect(fugutaFamily[2].firstName).toBe('タラオ');
    expect(fugutaFamily[2].age).toBe(3);
  } finally {
    await connection.close();
  }
});

カウント。

    // count
    expect(await entityManager.count(Post)).toBe(3);
    expect(await entityManager.count(User)).toBe(5);

1件取得。ただ、ここでは関連まで含めては取得できていません。

    // findOne
    const katsuo = await entityManager.findOne(User, { firstName: 'カツオ' });
    if (katsuo) {
      expect(katsuo.id).not.toBeUndefined();
      expect(katsuo.id).not.toBeNull();
      expect(katsuo.lastName).toBe('磯野');
      expect(katsuo.firstName).toBe('カツオ');
      expect(katsuo.age).toBe(11);
      expect(katsuo.posts).toBeUndefined(); // undefined
    } else {
      throw new Error('test fail');
    }

ソートなどを指定したい場合は、Repositoryを使います。

    // repository and find, order by
    const userRepository = entityManager.getRepository(User);
    const fugutaFamily = await userRepository.find({
      where: {
        lastName: 'フグ田',
      },
      order: {
        age: 'DESC',
      },
    });

    expect(fugutaFamily).toHaveLength(3);
    expect(fugutaFamily[0].firstName).toBe('マスオ');
    expect(fugutaFamily[0].age).toBe(28);
    expect(fugutaFamily[1].firstName).toBe('サザエ');
    expect(fugutaFamily[1].age).toBe(24);
    expect(fugutaFamily[2].firstName).toBe('タラオ');
    expect(fugutaFamily[2].age).toBe(3);

Repositoryというのは、EntityManagerに似たものです。ただ、操作対象が特定のEntityに制限されています。

What is Repository

詳しい使い方は、こちら。

Find Options

独自のRepositoryも作れるようですが、今回は省略。

Custom repositories

関連先も取得してみる

先ほどの例では、関連までは取得できませんでした。今度は関連まで取得してみましょう。

以下のように、relationsを指定します。

test('find relation', async () => {
  const connection = await createConnection(options);

  try {
    const entityManager = connection.manager;

    // findOne with relations
    const katsuo = await entityManager.findOne(
      User,
      { firstName: 'カツオ' },
      { relations: ['posts'] }
    );
    if (katsuo) {
      expect(katsuo.id).not.toBeUndefined();
      expect(katsuo.id).not.toBeNull();
      expect(katsuo.lastName).toBe('磯野');
      expect(katsuo.firstName).toBe('カツオ');
      expect(katsuo.age).toBe(11);

      const posts = katsuo.posts;
      expect(posts).toHaveLength(2);
      expect(posts[0].title).toBe(
        'はじめてのTypeScript(+TypeScript ESLint、Prettier、Emacs lsp-mode)'
      );
      expect(posts[1].title).toBe('JestでTypeScriptのテストを書く');
    } else {
      throw new Error('test fail');
    }
  } finally {
    await connection.close();
  }
});

こちらについても、検索に関するドキュメント内に書かれています。

Find Options

また、Eager Load、Lazy Loadについての概念もあるようです。

Eager and Lazy Relations

更新してみる

更新は、EntityManager#saveで行えます。

test('update', async () => {
  const connection = await createConnection(options);

  try {
    const entityManager = connection.manager;

    await entityManager.transaction(async (em) => {
      const tara = await em.findOne(User, { firstName: 'タラオ' });

      if (tara) {
        tara.age = tara.age + 1;
        await em.save(tara);
      } else {
        throw new Error('test fail');
      }
    });

    const tara = await entityManager.findOne(User, { firstName: 'タラオ' });
    if (tara) {
      expect(tara.firstName).toBe('タラオ');
      expect(tara.age).toBe(4);
    } else {
      throw new Error('test fail');
    }
  } finally {
    await connection.close();
  }
});

update等のメソッドもEntityManagerは備えています。

EntityManager API

Query Builder

Query Builderも使ってみましょう。

Select using Query Builder

Query Builderを使うことで、より柔軟にクエリーを組み立てることができます。EntityManagerやRepositoryでは難しい場合は、こちらですね。

test('query builder', async () => {
  const connection = await createConnection(options);

  try {
    const entityManager = connection.manager;

    const isonoFamily = await entityManager
      .getRepository(User)
      .createQueryBuilder('user')
      .innerJoinAndSelect('user.posts', 'post', 'post.user_id = user.id')
      .where('user.lastName = :lastName', { lastName: '磯野' })
      .orderBy('user.age', 'DESC')
      .addOrderBy('post.url', 'DESC')
      .getMany();

    expect(isonoFamily).toHaveLength(2);

    const katsuo = isonoFamily[0];
    expect(katsuo.firstName).toBe('カツオ');
    expect(katsuo.age).toBe(11);

    const katsuoPosts = katsuo.posts;
    expect(katsuoPosts).toHaveLength(2);
    expect(katsuoPosts[0].title).toBe('JestでTypeScriptのテストを書く');
    expect(katsuoPosts[1].title).toBe(
      'はじめてのTypeScript(+TypeScript ESLint、Prettier、Emacs lsp-mode)'
    );

    const wakame = isonoFamily[1];
    expect(wakame.firstName).toBe('ワカメ');
    expect(wakame.age).toBe(9);

    const wakamePosts = wakame.posts;
    expect(wakamePosts).toHaveLength(1);
    expect(wakamePosts[0].title).toBe('TypeScriptでExpress');
  } finally {
    await connection.close();
  }
});

Query Builderを使ってクエリーを組み立てている部分は、こちら。

    const isonoFamily = await entityManager
      .getRepository(User)
      .createQueryBuilder('user')
      .innerJoinAndSelect('user.posts', 'post', 'post.user_id = user.id')
      .where('user.lastName = :lastName', { lastName: '磯野' })
      .orderBy('user.age', 'DESC')
      .addOrderBy('post.url', 'DESC')
      .getMany();

なお、SQLそのものを使いたい場合は、EntityManager#queryを使えばできるようです。

EntityManager API

削除

データの削除は、EntityManager#deleteで。

test('clean up', async () => {
  const connection = await createConnection(options);

  try {
    const entityManager = connection.createEntityManager();
    await entityManager.delete(User, {});

    expect(await entityManager.count(User)).toBe(0);
    expect(await entityManager.count(Post)).toBe(0);
  } finally {
    await connection.close();
  }
});

truncateを使いたい場合は、EntityManager#queryを使うことになります。

まとめ

TypeORMを使ってみました。

パッと見はJPAそっくりなんですけど、Node.jsおよびTypeScriptに不慣れなところも多いのでハマりにハマりました…。

慣れればなんとかなりそうなので、こちらを使っていこうかなぁとは思います…。