TypeORMを使用して、TypeScriptでMySQLのマイグレーション、接続を管理する

## 概要

Node.jsでMySQLのORMを管理をする場合に最初に思いつくライブラリとしては[Sequelize](https://github.com/sequelize/sequelize/)がありますが、ある程度かっちり開発する人にとって、MySQLのマイグレーションやエンティティを折角コードで管理するのであれば、TypeScriptへ対応は必須かと思います。しかしSequelizeはver5でTSにネイティブ対応したものの、元々の設計から互換性はかなり厳しい様です。（参考: [Sequelize から TypeORM に移行してみた](https://scrapbox.io/dojineko/Sequelize_%E3%81%8B%E3%82%89_TypeORM_%E3%81%AB%E7%A7%BB%E8%A1%8C%E3%81%97%E3%81%A6%E3%81%BF%E3%81%9F)）
　そこで、TypeORMが選択肢に上がります。2020/05月時点でスター数が18.9kを超えており非常に期待されているTSベースのORMライブラリです。TypeORMは各種DBに対応していますが、本投稿では`MySQL`をTypeORMで触る方法を説明します。

サンプルコードはこちらに公開しています。　
https://github.com/hedrall/typeorm-sample

## セットアップ

まずは必要なツールをインストールする
(gitのサンプルを確認してみてください。)

```shell
npm i -g typeorm mysql reflect-metadata
```

tsconfig.jsonには下記の記述が必須になります。

```json:tsconfig.json
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
```

ルートディレクトリにapp.tsを作成します。

```ts:~/app.ts
import "reflect-metadata";
```

接続設定を記述したormconfig.jsを作成する

```js:~/ormconfig.js
module.exports = [
  {
    name: 'default', // 標準で読み込まれる設定
    type: 'mysql',
    host: 'localhost',
    port: 3306,
    username: 'docker',
    password: 'docker',
    database: 'test',
    synchronize: false,
    logging: false,
    connectTimeout: 30 * 1000,
    acquireTimeout: 30 * 1000,
    entities: [__dirname + '/dist/entity/**/*.js'],
    migrations: [__dirname + '/dist/migration/**/*.js'],
    // 今回subscriberは扱いません。
    // subscribers: [__dirname + '/dist/subscriber/**/*.js'],
    cli: {
      entitiesDir: 'src/entity',
      migrationsDir: 'src/migration',
      // subscribersDir: 'src/subscriber'
    }
  },
];
```

ポイントとして、entities, migrationsにはテーブル定義やマイグレーション定義を格納する場所ですが、参照先を設定する必要があります。今回はTSCを実行した時に~/src => ~/distに結果が吐き出される設定にしており、typeormのcliコマンド自体は当然nodeで動くので、dist内の`.js`ファイルを参照するようにしています。一方cliがファイルを生成する場合の格納先はsrc配下なので、src/entityなどを設定します。
一応 ts-nodeを使用して`./node_modules/typeorm/cli.js`を実行する方法もありますが、ORMライブラリを使用したコードのデプロイ時は.jsファイルを参照するはずなので、個人的にはこの方法がおすすめです。

## ローカル確認用にMySQLのコンテナを起動する

こんな感じでdocker-compose.ymlを書きました。

```yml:~/docker-compose.yml
version: '3'

services:
  mysql:
    image: mysql:5.7
    container_name: mysql_host
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: test
      MYSQL_USER: docker
      MYSQL_PASSWORD: docker
      TZ: 'Asia/Tokyo'
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    ports:
      - 3306:3306
```

コンテナを立ち上げます。

```shell
docker-compose up -d
```

MySQLに入ってみます。

```shell
mysql -u docker -p -h 127.0.0.1 # パスワードもdocker
```

初期状態ではtestというDBを作成しています。

```sql
mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| test               |
+--------------------+
2 rows in set (0.00 sec)
```

## エンティティ（テーブル定義）を作成

test_userというテーブルを作成してみます。カラムはusernameとemailとageだけでusernameとemailの両方でprimaryにします。

```ts:~/src/entity/TestUser.ts
import {
  Column,
  CreateDateColumn,
  Entity,
  PrimaryColumn,
  UpdateDateColumn
} from 'typeorm';

@Entity()
export class TestUser {
  // ユーザ名, VARCHAR(20)型
  // デコレータの引数で型を定義できる
  // string型の変数に関してデフォルトではvarchar(255)型になるため、lengthをつけて宣言することもできる
  @PrimaryColumn( { type: 'varchar', length: 20 } )
  username: string;

  // emailアドレス, VARCHAR(50)型
  @PrimaryColumn( { type: 'varchar', length: 50 } )
  email: string;

  // emailアドレス, INT(11)型
  @Column( { nullable: true } )
  age?: number;

  // レコードの作成時間, DATETIME(6)型
  @CreateDateColumn()
  created_at?: string;

  // レコードの更新時間, DATETIME(6)型
  @UpdateDateColumn()
  updated_at?: string;

  // 例えば自動生成列(Generated Column)の定義も可能, VARCHAR(10)型
  @Column( {
    type: 'varchar',
    length: 10,
    generatedType: 'STORED',
    asExpression: 'date_format(created_at,\'%Y%m%d\')'
  } )
  create_date?: string;

  constructor( options: TestUser ) {
    Object.assign( this, options );
  }
}
```

## マイグレーションを作成

TypeORMのマイグレーションファイルはエンティティの定義とDBでの現状との差分から自動的にSQLを生成可能です。実行するには

```shell
rm -rf dist && tsc
typeorm migration:generate -n CreateTestUser
```

を実行します。
するとmigrationファイルが自動生成されます。

```ts:~/src/migrate/1588663729665-CreateTestUser.ts
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateTestUser1588663729665 implements MigrationInterface {
  name = 'CreateTestUser1588663729665';

  public async up( queryRunner: QueryRunner ): Promise<void> {
    await queryRunner.query(
      'CREATE TABLE `test_user` (`username` varchar(20) NOT NULL, `email` varchar(50) NOT NULL, `age` int NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `create_date` varchar(10) AS (date_format(created_at,\'%Y%m%d\')) STORED NOT NULL, PRIMARY KEY (`username`, `email`)) ENGINE=InnoDB',
      undefined
    );
  }

  public async down( queryRunner: QueryRunner ): Promise<void> {
    await queryRunner.query( 'DROP TABLE `test_user`', undefined );
  }
}
```

この様に単純に差分をSQLに起こしてくれます。

続いてマイグレーションを実行します。

```shell
rm -rf dist && tsc
typeorm migration:run
```

そうすると、SQL通りにテーブルの作成が確認できました。
![スクリーンショット 2020-05-05 16.33.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/194978/876588a3-5403-8cab-62f6-bf82b4f7e1a9.png)

ちなみにrunコマンドでは、全ての未実施のmigrationが一度に実行されます。また、downの操作を行うには

```shell
typeorm migration:revert
```

ですが、最後のmigrationファイルのdownのみが実行されます。なので、一個づつupする、全てdownするという操作がありませんが、仕様の様です。
https://typeorm.io/#/migrations/running-and-reverting-migrations

## DBを操作する

DBにデータを挿入してみます。
CRUD操作にはクエリビルダーを使用してSQLの様に操作を指定する方法と、組み込みのORMモデル(manager)を使用する方法があります。下記ではmanagerを使用してデータを挿入します。

```ts:~/src/insert.ts
import { createConnection } from 'typeorm';
import { TestUser } from './entity/TestUser';

( async () => {
  // MySQLと接続
  const connection = await createConnection( 'default' );
  
  // データを挿入する
  await connection.manager.save( TestUser, [
    {
      username: 'user1',
      email: 'email1@example.com',
      age: 20
    },
    {
      username: 'user2',
      email: 'email2@example.com',
    }
  ] );
  
  await connection.close();
} )().catch( e => console.log( e ) );
```

```shell
tsc
node dist/insert.js
```

![スクリーンショット 2020-05-05 16.44.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/194978/cb70e6b1-cee6-e39e-060e-0c67eea21e0b.png)

> 再度実行しても重複列は自動的にスキップされます。

続いてSELECTで取得してみましょう。

```ts:~/src/select.ts
import { createConnection } from 'typeorm';
import { TestUser } from './entity/TestUser';

( async () => {
  // MySQLと接続
  const connection = await createConnection( 'default' );
  
  // データを取得する
  const result = await connection.manager.find( TestUser, { age: 20 } );
  
  console.table( result );
  
  await connection.close();
} )().catch( e => console.log( e ) );
```

```shell
$ tsc
$ node dist/select.js
┌─────────┬──────────┬──────────────────────┬─────┬──────────────────────────┬──────────────────────────┬─────────────┐
│ (index) │ username │        email         │ age │        created_at        │        updated_at        │ create_date │
├─────────┼──────────┼──────────────────────┼─────┼──────────────────────────┼──────────────────────────┼─────────────┤
│    0    │ 'user1'  │ 'email1@example.com' │ 20  │ 2020-05-05T07:44:29.166Z │ 2020-05-05T07:44:29.166Z │ '20200505'  │
└─────────┴──────────┴──────────────────────┴─────┴──────────────────────────┴──────────────────────────┴─────────────┘
```

## TIPS

- データをインサートする場合はsaveを使用すると、primari-key制約で重複する行はupdate(値が変化しなければ何もしない) 、それ以外はinsertをしてくれるが、複数のキーを使用している場合には全てinsertの処理がされる様子。[git](https://github.com/typeorm/typeorm/issues/4122)でも同様のエラーが議論されてました。 その場合は自分で重複を排除する必要があります。
- 重複判定には`connection.manager.getId`を使用すると、上の例では`user1-email1@example.com`の様なIDが返却されます。selectの結果ではdatetime型の列は Date オブジェクトで帰りますので、string変換する場合に予期せぬ形式になる可能性があるので、注意してください。
- GCPのCloudFunctionsで使用する場合はTCP接続ができないので、ormconfig.jsに下記を追加してください。

```js

extra: {
  socketPath: '/cloudsql/DBの接続名'
}
```

## 以上

参考になれば幸いです。