※ この記事は 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
によりデータベースレベルで制約を設けることで解決可能です。どの程度の信頼性を確保するかは設計者自身が決める必要があります。
PostgreSQL は Ruby 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 として切り出すことを推奨します。
- enum 属性が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によるリファクタリングなど、多くの学びがある非常に良質な内容だと感じました。また、日本語で同様の情報を見つけることができなかったので自分で翻訳してみました。より良い改善方法があればこちらでもコメントしてもらえると助かります。
最後までご覧いただきありがとうございました。