🍇

【Rails】enumのデメリットについて考える

2021/09/26に公開

はじめに

Railsでアプリケーションを構築する際にenumを使うことはよくあると思います。

しかし、DBにおけるenum型は「SQLアンチパターン」という書籍の「31 Flavors」という名前のアンチパターンとしても知られています。

今回はそんなenumのメリットやデメリットについて考えたのでまとめます。

前提

  • この記事で扱うenumはDBにおけるenum型ではなく、Railsの機能として提供されているenumを指して説明します。
  • Ruby: 2.7.1
  • Rails: 6.0.3.4
  • MySQL: 5.7.29

この記事の結論

  • enumを使うことは悪ではないが、メリットとデメリットを把握した上で使用すること。
  • enumのデメリットを補う解決策が存在することを認識しておくこと。
  • 適切なタイミングでenumを使わない選択肢(別テーブルとして切り出す方法)を取れるように考えておくこと。

enumとは

列挙型のことです。例えば、「男=0」「女=1」としてDB上に保存したい場合に使います。

例として、Userクラスがあるとします。属性として、「name(氏名)」「gender(性別)」「prefecture(都道府県)」を持っているとします。

genderとprefectureをenumとして定義します。

app/models/user.rb

class User < ApplicationRecord
  enum gender: { man: 0, woman: 1 }, _prefix: true
  enum prefecture: { tokyo: 0, osaka: 1, nagoya: 2 }, _prefix: true
end

DB上にはenumで定義した値が数値として保存されます。

SELECT * FROM users;

| id | name  | gender | prefecture | created_at                 | updated_at                 |
|----|-------|--------|------------|----------------------------|----------------------------|
|  1 | user1 |      0 |          0 | 2021-09-23 15:37:30.781431 | 2021-09-23 15:37:30.781431 |
|  2 | user2 |      0 |          1 | 2021-09-23 15:37:30.790889 | 2021-09-23 15:37:30.790889 |
|  3 | user3 |      1 |          0 | 2021-09-23 15:37:30.801230 | 2021-09-23 15:37:30.801230 |
|  4 | user4 |      1 |          2 | 2021-09-23 15:37:30.809486 | 2021-09-23 15:37:30.809486 |

rails consoleから値を取得すると、数値ではなくてenumで定義したmanwomanといった文字列で取り出すことができます。

Rails側から使うとき

User.first.gender
# => "man"

User.first.prefecture
# => "tokyo"

# 男性のUser一覧を取得したいとき
User.gender_man

# 東京都のUser一覧を取得したいとき
User.prefecture_tokyo

# 東京都以外のUser一覧を取得したいとき
User.not_prefecture_tokyo

# 定義されている性別一覧を取得したいとき
User.genders
# => {"man"=>0, "woman"=>1}

# 定義されている都道府県一覧を取得したいとき
User.prefectures
# => {"tokyo"=>0, "osaka"=>1, "nagoya"=>2}

enum以外の解決策

enumを使う以外の実装方法として、「enumで定義されている箇所を別テーブルとして切り出す」という方法があります。

今回の例では、gendersテーブルとprefecturesテーブルを作成します。

usersテーブル

| id | name   | gender_id | prefecture_id | created_at                 | updated_at                 |
|----|--------|-----------|---------------|----------------------------|----------------------------|
|  1 | user1 |         1 |             1 | 2021-09-23 15:37:30.930411 | 2021-09-23 15:37:30.930411 |
|  2 | user2 |         1 |             2 | 2021-09-23 15:37:30.939223 | 2021-09-23 15:37:30.939223 |
|  3 | user3 |         2 |             1 | 2021-09-23 15:37:30.950641 | 2021-09-23 15:37:30.950641 |
|  4 | user4 |         2 |             3 | 2021-09-23 15:37:30.959353 | 2021-09-23 15:37:30.959353 |

gendersテーブル

| id | name  | created_at                 | updated_at                 |
|----|-------|----------------------------|----------------------------|
|  1 | man   | 2021-09-23 15:37:30.863884 | 2021-09-23 15:37:30.863884 |
|  2 | woman | 2021-09-23 15:37:30.871785 | 2021-09-23 15:37:30.871785 |

prefecturesテーブル

| id | name   | created_at                 | updated_at                 |
|----|--------|----------------------------|----------------------------|
|  1 | tokyo  | 2021-09-23 15:37:30.887358 | 2021-09-23 15:37:30.887358 |
|  2 | osaka  | 2021-09-23 15:37:30.896177 | 2021-09-23 15:37:30.896177 |
|  3 | nagoya | 2021-09-23 15:37:30.905950 | 2021-09-23 15:37:30.905950 |

app/models/user.rb

class User < ApplicationRecord
  belongs_to :gender
  belongs_to :prefecture
end

Rails側から使うとき

User.first.gender.name
# => "man"

User.first.prefecture.name
# => "tokyo"

# 男性のUser一覧を取得したいとき
User.joins(:gender).merge(Gender.where(name: 'man'))

# 東京都のUser一覧を取得したいとき
User.joins(:prefecture).merge(Prefecture.where(name: 'tokyo'))

# 東京都以外のUser一覧を取得したいとき
User.joins(:prefecture).merge(Prefecture.where.not(name: 'tokyo'))

# 定義されている性別一覧を取得したいとき
Gender.all
# => [#<Gender:0x00007f94e6d10ee8
  id: 1,
  name: "man",
  created_at: Thu, 23 Sep 2021 15:37:30 UTC +00:00,
  updated_at: Thu, 23 Sep 2021 15:37:30 UTC +00:00>,
 #<Gender:0x00007f94e6d10e20
  id: 2,
  name: "woman",
  created_at: Thu, 23 Sep 2021 15:37:30 UTC +00:00,
  updated_at: Thu, 23 Sep 2021 15:37:30 UTC +00:00>]

# 定義されている都道府県一覧を取得したいとき
Prefecture.all
# => [#<Prefecture:0x00007f94e76eea00
  id: 1,
  name: "tokyo",
  created_at: Thu, 23 Sep 2021 15:37:30 UTC +00:00,
  updated_at: Thu, 23 Sep 2021 15:37:30 UTC +00:00>,
 #<Prefecture:0x00007f94e76ee8e8
  id: 2,
  name: "osaka",
  created_at: Thu, 23 Sep 2021 15:37:30 UTC +00:00,
  updated_at: Thu, 23 Sep 2021 15:37:30 UTC +00:00>,
 #<Prefecture:0x00007f94e76ee7f8
  id: 3,
  name: "nagoya",
  created_at: Thu, 23 Sep 2021 15:37:30 UTC +00:00,
  updated_at: Thu, 23 Sep 2021 15:37:30 UTC +00:00>]

enumのメリット・デメリット

では、先述したenumを別テーブルとして切り出す方法と比較したときのenumのメリット・デメリットについて考えます。

enumのメリット

メリット1. テーブルのjoinやselectの回数が減る

ユーザーの性別を文字列として取得したい場合で比較してみます。

enumで定義した場合

User.first.gender
  User Load (6.4ms)  SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1
=> "man"

別テーブルとして切り出した場合

User.first.gender.name
  User Load (2.8ms)  SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1
  Gender Load (3.0ms)  SELECT `genders`.* FROM `genders` WHERE `genders`.`id` = 1 LIMIT 1
=> "man"

別テーブルとして切り出した場合は、GenderテーブルにもSELECT文が走ることになるので、取得時のパフォーマンスは落ちてしまいます。

メリット2. 便利なメソッドがRails側で提供されている

enumを定義するだけで、User.gender_manUser.not_gender_manのような便利なメソッドを使えるようになるため、実装コストが減ります。

メリット3. ソースコードを修正するだけで新しい値を追加できる

DBへのINSERTなしに、ソースコードを修正すれば新しくenumの値を追加できるので、場合によっては新しい値を追加するための運用コストが少なくなります。

もし別テーブルとして切り出した場合に値を追加する場合、

  • 管理画面から新しい値を追加するためのUIを作成するのか考える必要がある
  • 会社によっては、安全性の理由からDBに直接クエリを叩くのに何重ものチェックが必要になるのが面倒くさい

といったデメリットが発生する可能性があり得ます。

enumのデメリット

デメリット1. enumの定義をDBからだけでは確認できない

  • enumの定義(genderの0が男性で1が女性を表すこと)を確認するには、DBではなくソースコードを確認しなければいけなくなります。

デメリット2. DBレベルでの制約をつけづらい

  • Railsでenumを実装するとき、DBとしての型はintegerで実装することが多いと思いますが、その場合はenumとして定義されていない値を格納することも可能になってしまいます。
    • genderは「0:男性、1:女性」しか定義されていないのに、DBに直接UPDATE文をかければ「2」という値に更新することも可能です。
  • DBのカラム自体をENUM型にすることも可能ですが、その場合は新しく値を追加するときにソースコードだけではなくUserテーブルに対してALTER TABLEをかける必要が出てくるので、運用コストが上がってしまいます。

デメリット3. 他のテーブルとリレーションを張れないため、正規化できない

個人的には、ここがenumを使う上で一番デメリットになり得る部分だと考えています。逆に、他のテーブルからリレーションを張って正規化する必要がなければ、enumとして定義しておいて問題ないかと。

例として、業務上で以下の要件が発生したとします。

  • ユーザーへのお知らせ(Notification)を表示する画面がほしい。
  • ユーザーの性別や都道府県に応じて、ユーザー毎に表示するお知らせを変えたい。条件は複数選択できる。
    • 男性にだけ表示されるお知らせ
    • 東京都or大阪府に住んでいるユーザーにだけ表示されるお知らせ
    • 「東京都or大阪府に住んでいる」かつ「女性」のユーザーにだけ表示されるお知らせ

複数選択できるようにという要件なので、Notificationに「gender」や「prefecture」カラムを生やすというだけでは実現できません。

今回は、json型を使って実装することにしたとします。(説明は省きますが、json型はつらいことが多いので基本的に使うべきではありません)

Notificationテーブル

  • title: お知らせのタイトル
  • body: お知らせの本文
  • display_condition: 表示条件
| id | title         | body       | display_condition                       | created_at                 | updated_at                 |
|----|---------------|------------|-----------------------------------------|----------------------------|----------------------------|
|  1 | notifiaction1 | 大事なお知らせです1 | {"genders": [], "prefectures": []}      | 2021-09-23 15:37:30.825805 | 2021-09-23 15:37:30.825805 |
|  2 | notifiaction2 | 大事なお知らせです2 | {"genders": [0], "prefectures": []}     | 2021-09-23 15:37:30.833728 | 2021-09-23 15:37:30.833728 |
|  3 | notifiaction3 | 大事なお知らせです3 | {"genders": [], "prefectures": [0, 1]}  | 2021-09-23 15:37:30.841235 | 2021-09-23 15:37:30.841235 |
|  4 | notifiaction4 | 大事なお知らせです4 | {"genders": [1], "prefectures": [0, 1]} | 2021-09-23 15:37:30.849499 | 2021-09-23 15:37:30.849499 |

app/models/notification.rbの一例

class Notification < ApplicationRecord
  class << self
    def displayable_to(user)
      notification_ids = Notification.all.select { |notification| notification.displayable_to?(user) }.map(&:id)
      Notification.where(id: notification_ids)
    end
  end

  def displayable_to?(user)
    (display_condition['genders'].blank? || display_condition['genders'].include?(user.gender_before_type_cast)) &&
      (display_condition['prefectures'].blank? || display_condition['prefectures'].include?(user.prefecture_before_type_cast))
  end
end

(※表示条件を選択しなかった場合は空の配列が入ることとする)

このように実装することも可能ではあるのですが、いくつか問題点があります。

  • 存在しない値を追加することができる(参照整合性を保てない)。
    • "genders": [3]のような値を入れることができてしまう。
  • 第1正規形に違反している。
    • これはenumのデメリットというよりjson型のデメリットですが、「1つのカラムの中には1つの値」という第1正規形の定義に違反しており、RDBの根本的な考え方に逆らっていることになります。

上記の実装を、genderやprefectureをテーブルとして定義していた場合、以下のようにテーブル間でリレーションを張ることができます。

notificationテーブル

| id | title         | body       | created_at                 | updated_at                 |
|----|---------------|------------|----------------------------|----------------------------|
|  1 | notifiaction1 | 大事なお知らせです1 | 2021-09-23 15:37:30.825805 | 2021-09-23 15:37:30.825805 |
|  2 | notifiaction2 | 大事なお知らせです2 | 2021-09-23 15:37:30.833728 | 2021-09-23 15:37:30.833728 |
|  3 | notifiaction3 | 大事なお知らせです3 | 2021-09-23 15:37:30.841235 | 2021-09-23 15:37:30.841235 |
|  4 | notifiaction4 | 大事なお知らせです4 | 2021-09-23 15:37:30.849499 | 2021-09-23 15:37:30.849499 |

notification_gendersテーブル

| id | notification_id | gender_id | created_at                 | updated_at                 |
|----|-----------------|-----------|----------------------------|----------------------------|
|  1 |               2 |         1 | 2021-09-23 15:37:31.028341 | 2021-09-23 15:37:31.028341 |
|  2 |               4 |         2 | 2021-09-23 15:37:31.037408 | 2021-09-23 15:37:31.037408 |

notification_prefecturesテーブル

| id | notification_id | prefecture_id | created_at                 | updated_at                 |
|----|-----------------|---------------|----------------------------|----------------------------|
|  1 |               3 |             1 | 2021-09-23 15:37:31.055711 | 2021-09-23 15:37:31.055711 |
|  2 |               3 |             2 | 2021-09-23 15:37:31.064761 | 2021-09-23 15:37:31.064761 |
|  3 |               4 |             1 | 2021-09-23 15:37:31.075442 | 2021-09-23 15:37:31.075442 |
|  4 |               4 |             2 | 2021-09-23 15:37:31.083863 | 2021-09-23 15:37:31.083863 |

app/models/notification.rbの一例

class Notification < ApplicationRecord
  has_many :notification_genders
  has_many :notification_prefectures
  has_many :genders, through: :notification_genders
  has_many :prefectures, through: :notification_prefectures

  class << self
    def displayable_to(user)
      Notification.left_joins(:notification_genders, :notification_prefectures)
                  .merge(NotificationGender.where(id: nil).or(NotificationGender.where(gender_id: user.gender_id)))
                  .merge(NotificationPrefecture.where(id: nil).or(NotificationPrefecture.where(prefecture_id: user.prefecture_id)))
                  .order(:id)
    end
  end
end

(※表示条件を選択しなかった場合、notification_gendersやnotification_prefecturesテーブルのレコードは作成されないこととする)

上記のように実装することで、DBの正規化を保つことができ、gender_idに外部キー制約を張っておけば存在しないgender_idを格納してしまうことも防げます。

基本的にRDBは正規化しておくべきだというのがこれまでの先人たちの経験則なので、今回の例では別テーブルとして切り出す実装方法を選択すべきでしょう。

いつenumを使い、いつテーブルとして切り出すべきか

Railsのenumは用意されているメソッドが多く便利なので、基本的にenumを使う方針で問題ないと思います。

ただし、以下のような場合は別テーブルとして切り出すべきでしょう。

  • 他のテーブルからリレーションを張って正規化したい
  • 参照整合性を保たないと致命的なバグになり得る

さいごに

今回はenumのメリットとデメリットについて考えてみました。以下、この記事の結論の再掲です。

  • enumを使うことは悪ではないが、メリットとデメリットを把握した上で使用すること。
  • enumのデメリットを補う解決策が存在することを認識しておくこと。
  • 適切なタイミングでenumを使わない選択肢(別テーブルとして切り出す方法)を取れるように考えておくこと。

少しでも参考になれば幸いです。
また、よりよい解決策やご意見がありましたら、ご教示いただけるとうれしいです。

Discussion