Node.js+Sequelize+MySQLでプライマリーキーをUUIDにする

[PONOS Advent Calendar 2020](https://qiita.com/advent-calendar/2020/ponos)の6日目の記事です。

昨日は[@kerimeka](https://qiita.com/kerimeka)さんのAndroid開発者向けオプションを使ったチート対策でした。

# はじめに
ORマッパーを使う場合、データベースのPK（仮にIDとします）はデフォルトだとオートインクリメントな数値型となっていることが多いと思います。
場合によってはこれが望まれないケースもあり、IDにUUIDが用いられているケースも見かけますので、今回はNode.jsのORマッパーの一つであるSequelizeを使って簡単にこれを実現してみたいと思います。

# この記事の対象者
Node.jsとSequelizeの基本的な知識があることを前提とします。
この記事ではこれらの細かい部分については言及しません。

# 検証環境
下記の構成で試しました。

- Node.js 12.19.0
- Sequelize 6.3.5
- Sequelize-CLI 6.2.0
- uuid 8.3.1
- MySQL 5.7.25

# UUIDについて

## UUIDとは
UUIDについては沢山記事がありますので簡単な概要だけ記載させていただきますが、現実的にはまず重複することなく発行できるIDで、５つのバージョンがあり生成方法が異なります。
ここでは乱数によって生成されるUUIDv4を用います。

## なぜUUIDにするのか
データのIDが数値型であった場合、主に下記の2点が懸念される部分かと思います。
（必ずしも全てのアプリでこれが問題になると言うわけではないですが）

#### URLから他のデータを類推できる
REST的なAPIなどなどでURLにIDが指定されることがよくありますが、数値の場合はその値から連番で他のデータを類推できます。
#### データの総数が推測できる
IDが数値であることから、そのまま使用するとデータ数がなんとなく見えてしまうこともあります。

# 実現してみる

## まずは雛形のModelを作成する
Sequelize-CLIを使ってModelファイルとマイグレーションファイルを作成します。
ここでは`user`というテーブルに`name`と`age`フィールドを持たせます。

    npx sequelize model:generate --name user --underscored --attributes name:string,age:integer

※ `--underscored`を指定しているのはテーブル定義上は名称をスネークケース、コード上はキャメルメースにしたいだけで、本質的には今回の件と関係ありません。

できあがったコードをみてみると下記の状態になっています。

#### マイグレーションファイル: xxxxxxxxxxx-create-user.js

```JavaScript
'use strict';
module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.createTable('users', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      name: {
        type: Sequelize.STRING
      },
      age: {
        type: Sequelize.INTEGER
      },
      created_at: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updated_at: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
  },
  down: async (queryInterface, Sequelize) => {
    await queryInterface.dropTable('users');
  }
};
```

#### Modelファイル: user.js

```JavaScript
'use strict';
const {
  Model
} = require('sequelize');
module.exports = (sequelize, DataTypes) => {
  class user extends Model {
    /**
     * Helper method for defining associations.
     * This method is not a part of Sequelize lifecycle.
     * The `models/index` file will call this method automatically.
     */
    static associate(models) {
      // define association here
    }
  };
  user.init({
    name: DataTypes.STRING,
    age: DataTypes.INTEGER
  }, {
    sequelize,
    modelName: 'user',
    underscored: true,
  });
  return user;
};
```

## マイグレーションファイルのIDの定義をUUIDにする
上記のファイルをみてわかるとおり、Sequelize-CLIの`model:generate`を使用した場合、`id`,`created_at`,`updated_at`はデフォルトで定義されます。
明示的に`id`を指定しても定義を上書きできるわけではなく、`id`が二つ定義されたマイグレーションファイルが出力されるようでした。
この辺りもうちょっと楽にできたらなーと思うのですが・・・軽く調べた限りでは見つけられませんでした。

ということで上記の定義ファイルを変更する必要があります。

#### xxxxxxxxxxx-create-user.jsを編集する
##### 編集前
```JavaScript
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
```

##### 編集後
```JavaScript
      id: {
        allowNull: false,
        primaryKey: true,
        type: Sequelize.UUID
      },
```

これでテーブル定義がUUID用になります。
MySQLの場合は`CHAR(36) BINARY`が用いられるようです。

マイグレーションを実行してみましょう。

    npx sequelize db:migrate

この状態でINSERTを実行してみます。

```JavaScript
    await db.user.create({
        name: "sample",
        age: 10
    });
```

しかしこれはエラーになります。

    SequelizeDatabaseError: Field 'id' doesn't have a default value

IDをデフォルト値でINSERTしようとしていますが、テーブル定義上にデフォルト値が設定されていないからです。
SequelizeのUUID型のドキュメントによると、下記のような記載があります。

    use defaultValue: Sequelize.UUIDV1 or Sequelize.UUIDV4 to make sequelize generate the ids automatically

SequelizeがIDを自動生成するために`defaultValue`の指定が必要っぽいということです。

## UUIDのdefaultValueを指定する

ここが一つややこしいポイントで、`defaultValue`はマイグレーションファイルにもModel定義にも書くことができます。

まずマイグレーションファイルのほうにデフォルト値を指定するとどうなるか試してみます。

#### xxxxxxxxxxx-create-user.jsを編集する
##### 編集前
```JavaScript
      id: {
        allowNull: false,
        primaryKey: true,
        type: Sequelize.UUID
      },
```

##### 編集後
```JavaScript
      id: {
        allowNull: false,
        primaryKey: true,
        type: Sequelize.UUID,
        defaultValue: Sequelize.UUIDV4
      },
```

この状態で一度マイグレーションをUNDOし、もう一度実行します。

    npx sequelize db:migrate:undo
    npx sequelize db:migrate

テーブル定義を確認しますが、デフォルト値は何も設定されていません。
MySQLではデフォルトの定数にUUIDにしてくれみたいなものを設定することはできないはずので、当然かなと思います。

これはマイグレーションファイルなので実行時には関係がないはずですが、念のために実際に実行してみましたが、やはりエラーとなります。

どうやらModel側（つまりDB側のデフォルト値にたよるわけではない）での対処が必要ということです。予想通りですが。。

#### user.jsを編集する

user.jsのほうでdefaultValueを指定します。
今は`id`についての記載が一切ないので、下記のようにIDを改めて定義し、defaultValueを指定します。
ポイントとなる点として、マイグレーションファイルの方と違って、コマンドで生成したModel側のType指定には、引数に渡ってきている変数`DataTypes`が使われています。
（`Sequelize.STRING`ではなく、`DataTypes.STRING`になってる）
そこで`DataTypes.UUIDV4`を指定してみます。

```JavaScript
'use strict';
const {
  Model
} = require('sequelize');
module.exports = (sequelize, DataTypes) => {
  class user extends Model {
    /**
     * Helper method for defining associations.
     * This method is not a part of Sequelize lifecycle.
     * The `models/index` file will call this method automatically.
     */
    static associate(models) {
      // define association here
    }
  };
  user.init({
    id: {
      allowNull: false,
      primaryKey: true,
      type: DataTypes.UUID,
      defaultValue: DataTypes.UUIDV4,
    },
    name: DataTypes.STRING,
    age: DataTypes.INTEGER
  }, {
    sequelize,
    modelName: 'user',
    underscored: true,
  });
  return user;
};
```

これで実行してみると、どうやらうまく動作し、DBに下記のデータが登録されました。

|id|name|age|
|:--|:--|:--|
|99447f3e-328b-4a4b-8067-967c349c901b|sample|10|

## 他の方法

#### `Sequelize.UUIDV4`をインポートしてみる

Modelファイルでは`sequelize`からインポートしている箇所があるので、ここでUUIDV4をインポートしても、結果はうまく動作しました。

```JavaScript
'use strict';
const {
  Model, UUIDV4
} = require('sequelize');
module.exports = (sequelize, DataTypes) => {
  class user extends Model {
    /**
     * Helper method for defining associations.
     * This method is not a part of Sequelize lifecycle.
     * The `models/index` file will call this method automatically.
     */
    static associate(models) {
      // define association here
    }
  };
  user.init({
    id: {
      allowNull: false,
      primaryKey: true,
      type: DataTypes.UUID,
      defaultValue: UUIDV4,
    },
    name: DataTypes.STRING,
    age: DataTypes.INTEGER
  }, {
    sequelize,
    modelName: 'user',
    underscored: true,
  });
  return user;
};
```

引数のDataTypesも名称がそうなっているだけで、実態としてはSequelizeということなのでしょうか。

#### `uuid`の関数を直接指定する

uuidパッケージを使用してそれを`defaultValue`に指定してもうまく動作します。

```JavaScript
'use strict';
const {
  Model
} = require('sequelize');
const uuid = require('uuid');
module.exports = (sequelize, DataTypes) => {
  class user extends Model {
    /**
     * Helper method for defining associations.
     * This method is not a part of Sequelize lifecycle.
     * The `models/index` file will call this method automatically.
     */
    static associate(models) {
      // define association here
    }
  };
  user.init({
    id: {
      allowNull: false,
      primaryKey: true,
      type: DataTypes.UUID,
      defaultValue: uuid.v4,
    },
    name: DataTypes.STRING,
    age: DataTypes.INTEGER
  }, {
    sequelize,
    modelName: 'user',
    underscored: true,
  });
  return user;
};
```

ただし注意が必要な点があります。`uuid.v4`は関数です。これを下記の様に指定すると大変なことになります。

```JavaScript
    defaultValue: uuid.v4(),
```

考えてみると当然ですが、この場合はこれが読み込まれた際に決定されたUUIDを全てのINSERTで使用するため、2回目以降はエラーになります。

少し中身のコードを調べてみましたが、`defaultValue`は関数が設定されている場合、INSERT時にそれをSequelizeが実行して値を決定しているようです。もちろん固定値を渡すと毎回それが代入されるようです。

# まとめ
ということでIDをUUIDにすることは出来ましたが、CLIのコマンドで良い感じに生成できたら楽なのですが、この方法だと（少しですが）手間が入ります。

どなたか良い感じの方法ご存知でしたらご教授いただけますと幸いです。

明日は[@e73ryo](https://qiita.com/e73ryo)さんです！