55
41

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Rails】5ステップでイケてる enum を作る(翻訳)

Last updated at Posted at 2020-03-01

※ この記事は Ruby on Rails - How to Create Perfect Enum in 5 Steps を許可をとって翻訳したものです。日本語で読みやすいように一部意訳しております。

はじめに

エンジニアの皆さんなら客先からの仕様変更なんてものは日常茶飯事でしょう。度々来る仕様変更に対して柔軟に対応できるモデルを設計する必要があります。大抵のモデルはその属性を表すカラムを持っているでしょう。例えば宅配モデルなら「通常配送」「お急ぎ便」「時間指定」などの属性を持つことが想定されます。このような属性を定義する方法の一つが列挙型、すなわち enum です。

Rails では4.1以降で enum をサポートしています。

この記事は下記のような構成になっています。

  • 基本的な使い方 ーActiveRecord::Enumを出来るだけ簡単に導入する
  • 5ステップで enum を改善する
  • 5ステップ全部載せ

わかりやすくするために実際の例を用いていきます。ここでは作品モデル Artworks とそれを収録するカタログモデル Catalogs を考えます。また、Catalogs が持つ属性の中でも以下の4つを扱うことにしてみます。

  • state: ["incoming", "in_progress", "finished"]
  • auction_type: ["traditional", "live", "internet"]
  • status: ["published", "unpublished", "not_set"]
  • localization: ["home", "foreign", "none"]

基本的な使い方

既存のモデルに enum を追加するのは簡単です。まず最初にマイグレーションファイルを作成します。Rails では enum を用いるのに integer 型のカラムを定義します。

rails g migration add_status_to_catalogs status:integer
class AddStatusToCatalogs < ActiveRecord::Migration[5.1]
  def change
    add_column :catalogs, :status, :integer
  end
end

モデルに enum を宣言します。

class Catalog < ActiveRecord::Base
  enum status: [:published, :unpublished, :not_set]
end

そして、マイグレーションを実行しましょう。これで便利な追加メソッドをたくさん使えるようになりました。

例えば、次のようにして現在のステータスをチェックすることができます。

catalog.published? # false

属性の値の変更は次のようにします。

catalog.status = "published" # published
catalog.published! # published

値が published のカタログ一覧は次のようにすれば取得できます。

Catalog.published

全てのメソッドを見るには ActiveRecord::Enum を参照してください。

以上の方法は非常にシンプルで便利ですが、プロジェクトが進むといくつかの問題に直面するでしょう。備えあれば憂いなし、いくつかの準備をすることでメンテナンス性の高い enum を作ることができます。

5ステップで enum を改善する

STEP1 enum を配列ではなくハッシュで定義する

enum を配列で定義すると、DBで保持される整数値は、配列の順番に依存します。

class Catalog < ActiveRecord::Base
  enum localization: [:home, :foreign, :none]
end
0 -> home
1 -> foreign
2 -> none

この方法は柔軟性が全くありません。例えば、 "foreign" が "America" と "Asia" に分割された時、古い値を削除して新しい値を追加する必要があります。しかし、このケースでは "foreign" を削除すると "foreign" に割り当てられていた整数値 1 が他の属性に割り当てられ、過去の紐付けとの食い違いが発生してしまいます。

ハッシュで定義することでこの問題を回避することができます。

class Catalog < ActiveRecord::Base
  enum localization: { home: 0, foreign: 1, none: 2 }
end

この方法なら宣言の順番に依存することなく整数値を割り当てることができるため、属性の削除や追加が可能になります。

STEP2 ActiveRecord::Enum と PostgreSQL enum を紐付ける

rails 側で属性と整数値を紐付けた enum を定義した時、DB側が保持するのは整数値のみです。当然、整数値自体は意味を持たない値なのでDB上の値を見ても「1」が何を指しているのかは分かりません。rails consoleで次のように where メソッドを使うとエラーになってしまいます。

> Catalog.where.not(“state = ?”, “finished”)

ActiveRecord::StatementInvalid: PG::InvalidTextRepresentation:
ERROR: invalid input syntax for integer: "finished"

このエラーはDB側では "finished" という値ではなく整数値を保持しているために起こってしまいます。

ActiveRecordを介さない SQL クエリを実行するときにも同様の問題が発生します。単純にDBに保存されている情報を見ようとしただけでも、どの整数値がどの属性に対応するかを逐一確認する必要があるため、非常に手間がかかります。

このように、整数型の enum を用いることで情報が失われてしまうことを理解しておく必要があります。

さらに、データの安全性に関する問題もあります。例えば、DB側では整数値であれば insert 可能ですので、enum で宣言した値以外の値が insert されてしまうことも起こり得ます。この問題はPostgreSQL enumによりデータベースレベルで制約を設けることで解決可能です。どの程度の信頼性を確保するかは設計者自身が決める必要があります。

PostgreSQLRuby on Rail のプロダクトで標準的に使用されているデータベースです。PostgreSQLはテーブルの中で属性値を扱うのに適しています。

それでは、早速実装してみましょう。

rails g migration add_status_to_catalogs status:catalog_status

データ型を変更する時、 "status" のような命名はおすすめできません。近い将来別の "status" という属性が必要になる可能性が大いにあるからです。

次は、マイグレーションファイルを編集します。マイグレーションファイルは基本的に可逆的で SQL を実行できる必要があります。

class AddStatusToCatalogs < ActiveRecord::Migration[5.1]
  def up
    execute <<-SQL
      CREATE TYPE catalog_status AS ENUM ('published', 'unpublished', 'not_set');
    SQL
    add_column :catalogs, :status, :catalog_status
  end

  def down
    remove_column :catalogs, :status
    execute <<-SQL
      DROP TYPE catalog_status;
    SQL
  end
end

enum の宣言部分は前回のものに少し変更が必要です。

class Catalog < ActiveRecord::Base
  enum status: { published: "published", unpublished: "unpublished", not_set: "not_set" }
end

STEP3 index を enum で定義した属性に追加する

この変更はシンプルなものです。enum 属性はモデル内の特定のオブジェクトを抽出するときによく使われます。例えば、カタログモデルの中で "published" のものと そうでないものをリスト化する時などです。このようなフィルタリングの処理は非常に頻繁に行われるので index を追加しておくことはパフォーマンスの向上に繋がります。

次のようにマイグレーションファイルを修正しましょう。

class AddIndexToCatalogs < ActiveRecord::Migration
  def change
    add_index :catalogs, :status
  end
end

STEP4 prefix または suffix オプションを使う

今一度 Catalog モデルを見てみましょう。

  • state: ["incoming", "in_progress", "finished"]
  • auction_type: ["traditional", "live", "internet"]
  • status: ["published", "unpublished", "not_set"]
  • localization: ["home", "foreign", "none"]

prefix または suffix は次のようにして enum に追加します。

class Catalog < ActiveRecord::Base
  enum status: { published: "published", unpublished: "unpublished", not_set: "not_set" }, _prefix: :status
  enum auction_type: { traditional: "traditional", live: "live", internet: "internet" }, _suffix: true
end

なぜこれが役立つのでしょうか。Catalog モデルを見てみると、4つの enum と 12 の属性値を持っていることが分かります。すなわち、12のスコープを持つことになり、直感的には非常に分かりづらいものになっています。

Catalog.not_set
Catalog.live
Catalog.unpublished
Catalog.in_progress

上記のメソッドがどんな値が返すかすぐ答えるためには、全ての enum 属性をを常に覚えておく必要があります。とても大変なことです。

Catalog.status_not_set
Catalog.live_auction_type
Catalog.status_unpublished
Catalog.state_in_progress

だいぶ分かりやすくなりました。

ここでもう一つCatalogに enum を加えることになったとしましょう。グローバルカタログ内の各カタログの順序に関する情報を保持する enum です。一部のカタログの順序は指定されていない場合があります。最も重要なのは、どのカタログが最初でどれが最後かが分かることです。次のように作成します。

class Catalog < ActiveRecord::Base
  enum order: { first: "first", last: "last", other: "other", none: "none" }
end

では、rails consoleで作成した enum をチェックしてみましょう。

> Catalog.order

ArgumentError: You tried to define an enum named "order" on
 the model "Catalog", but this will generate a class method
 "first", which is already defined by Active Record.

"first" は既に ActiveRecord で定義されているというエラーが表示されました。そこで次のように修正します。

class Catalog < ActiveRecord::Base
  enum order: { first_catalog: "first_catalog", last_catalog: "last_catalog", other: "other", none: "none" }
end

再度チェックしてみます。

> Catalog.order

ArgumentError (You tried to define an enum named "order" on the
model "Catalog", but this will generate an instance method
"none?", which is already defined by another enum.)

先程と違うエラーです。"none" が別の enum で使われていることを指摘されています。

prefix または suffixs オプションはこの問題を解決するのに最適です。"first" "last" のような属性値自体はシンプルなまま残すことができます。また、スコープは直感的で分かりやすいものとなります。変更後のコードは次のようになります。

class Catalog < ActiveRecord::Base
  enum order: { first: "first", last: "last", other: "other", none: "none" }, _prefix: :order
end

STEP5 enum を Value Object として切り出す

次のような状態の場合は enum 属性をValue Object として切り出すことを推奨します。

  1. enum 属性が2つ以上のモデルで使われている場合
  2. enum 属性がモデルを複雑にする特定のロジックを持っている場合

それでは例を用いて説明します。我々のプロジェクトではアートワークを販売するオークションハウスを全国に配置しています。ポーランドは voivodeships(日本で言うところの県)と呼ばれる16の地域に分かれています。各AuctionHouseモデルはVoivodeship属性を含むAddressモデルを持っています。

なんらかの理由で下記のメソッドを実装することになったとします。

  • 北部のオークションハウスをリスト化するメソッド
  • 人口の多いいくつかの県のオークションハウスをリスト化するメソッド

これらのメソッドをAddressモデルに実装すると、モデルが肥大化してしまいます。そこで、別のクラスに切り出すことで再利用可能かつよりクリーンな状態を実現します。

class Voivodeship
  VOIVODESHIPS = %w(dolnoslaskie kujawsko-pomorskie lubelskie    lubuskie lodzkie
    malopolskie mazowieckie opolskie podkarpackie podlaskie
    pomorskie slaskie swietokrzyskie warminsko-mazurskie
    wielkopolskie zachodnio-pomorskie).freeze
  NORTHERN_VOIVODESHIPS = %w(warminsko-mazurskie pomorskie zachodnio-pomorskie podlaskie).freeze
  MOST_POPULAR_VOIVODESHIPS = %w(dolnoslaskie mazowieckie slaskie malopolskie).freeze

  def initialize(voivodeship)
    @voivodeship = voivodeship
  end

  def northern?
    NORTHERN_VOIVODESHIPS.include? @voivodeship
  end

  def popular?
    MOST_POPULAR_VOIVODESHIPS.include? @voivodeship
  end

  def eql?(other)
    to_s.eql?(other.to_s)
  end

  def to_s
    @voivodeship.to_s
  end
end

次にAddressモデルから切り出したVoivodeshipを呼び出す部分を記述します。array_to_enum_hashは配列で定義された enum をハッシュに変換するメソッドです。

class Address < ApplicationRecord
  enum voivodeship: array_to_enum_hash(Voivodeship::VOIVODESHIPS), _sufix: true

  def voivodeship
    @voivodeship ||= Voivodeship.new(read_attribute(:voivodeship))
  end
end

これでVoivodeshipsに関連するロジック全体が単一のクラスにカプセル化されました。必要に応じて拡張可能で、Addressが肥大化することもありません。

voivodeships 属性を取得したいときは、Voivodeshipsクラスが返されます。これはまさに Value Object です。

※Value Object はデザインパターンの一つです。こちら等が参考になります。

voivodeship_a = Address.first.voivodeship
# #<Voivodeship:0x000000000651eef0 @voivodeship="pomorskie">

voivodeship_b = Address.second.voivodeship
# #<Voivodeship:0x00000000064e9cf0 @voivodeship="pomorskie">

voivodeship_c = Address.third.voivodeship
# #<Voivodeship:0x000000000641ef00 @voivodeship="lodzkie">

voivodeship_a と voivodeship_b は同じ voivodeship の値を持っていますが、オブジェクトとしてはイコールではありません。幸いなことに、我々が作ったメソッドは値が等しいかをチェックすることができます。

voivodeship_a.eql? voivodeship_b
# true

voivodeship_a.eql? voivodeship_c
# false

さらに、先程定義したメソッドを使用して次のように記述できるのも非常に強力なメリットです。

voivodeship_a.northern? # true
voivodeship_a.popular? # false

voivodeship_c.northern? # false
voivodeship_c.popular? # false

5ステップ全部載せ

ここまで5ステップに渡って enum の改善方法を示してきました。それでは、ここまでの振り返りとして、これら全てを実装した究極の enum を作っていきましょう。例として、Catalogモデルのstatus属性を考えます。

マイグレーションファイルの作成

rails g migration add_status_to_catalogs status:catalog_status

マイグレーションファイルの編集

class AddStatusToCatalogs < ActiveRecord::Migration[5.1]
  def up
    execute <<-SQL
      CREATE TYPE catalog_status AS ENUM ('published', 'unpublished', 'not_set');
    SQL
    add_column :catalogs, :status, :catalog_status
    add_index :catalogs, :status
  end

  def down
    remove_column :catalogs, :status
    execute <<-SQL
      DROP TYPE catalog_status;
    SQL
  end
end

Value Object の作成

class CatalogStatus
  STATUSES = %w(published unpublished not_set).freeze

  def initialize(status)
    @status = status
  end

  # what you need here
end

Catalog モデルと enum の定義

class Catalog
  enum status: array_to_enum_hash(CatalogStatus::STATUSES), _sufix: true

  def status
    @status ||= CatalogStatus.new(read_attribute(:status))
  end
end

結論

以上がイケてる enum を作る5つのステップです。

これら全てが必要になることもあるし、一部だけ使うこともあります。自分のプロジェクトのニーズに合わせて調整してください。

最後に、この記事が誰かの訳に立つことを願っています。より良い改善方法があればコメントをよろしくお願いします。

この記事について

冒頭でも述べたとおり、下記を翻訳したものです。
Ruby on Rails - How to Create Perfect Enum in 5 Steps

著者に掲載の許可を取って公開しています。

enum 以外にもマイグレーションの可逆性、Value Objectによるリファクタリングなど、多くの学びがある非常に良質な内容だと感じました。また、日本語で同様の情報を見つけることができなかったので自分で翻訳してみました。より良い改善方法があればこちらでもコメントしてもらえると助かります。

最後までご覧いただきありがとうございました。

55
41
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
55
41

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?