やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

【ええな〜コード】作って学ぶRailsのenumの仕組みについて

Railsを使っていて何かと便利なのが「enum」です。
他の言語では列挙型(Enumerable)と呼ばれることがありますが、実態としてはActiveRecordに定義されたenumという関数です。modelファイルの中でカラム値のバリエーションを指定することが出来るのが非常に便利です。

# Sushiモデルのkindにはenumに定義した値しか登録できない
class Sushi
  enum kind: { nigiri: 0, maki: 1, gunkan: 2, other: 99 }
  validates :kind, inclusion: { in: Sushi.kinds.keys }
end

しかし、データベース上ではenum型にはならずINTEGERとして登録されます。個人的にはSMALLINTでもいいんじゃないかと思います。

さらにenumを定義するといくつかのメソッドが自動で定義されます。個人的にはレコードのカラム値が指定値どうか判定できるメソッド(eg: nigiri?)が便利でよく使っています。

# enum情報の取得
Sushi.kinds # { nigiri: 0, maki: 1, gunkan: 2, other: 99 }

# Sushiクラスのインスタンスのkindが0(nigiri)かどうか
Sushi.new(kind: 0).nigiri? # true

# Sushiクラスのインスタンスのkindを1(maki)に更新する
Sushi.new(kind: 0).maki! # 

# Sushiクラスに対応するテーブルからkindが0(nigiri)の一覧を取得 ≒ Sushi.where(kind: 0)
Sushi.nigiri

今回は便利なenumがどのようにして作られているのか実際のコードを見ながら、自分で実装してみることで理解を深めていきます。

enumのコード

まずはenumのコードを見てみます。先ほど書いたようにenumは関数として定義されています。
(※見やすさのため一部を省略しています。全体はURLよりご確認ください)

def enum(definitions)
  klass = self
  enum_prefix = definitions.delete(:_prefix)
  enum_suffix = definitions.delete(:_suffix)
  definitions.each do |name, values|
    # statuses = { }
    enum_values = ActiveSupport::HashWithIndifferentAccess.new

    # def self.statuses() statuses end
    detect_enum_conflict!(name, name.to_s.pluralize, true)
    klass.singleton_class.send(:define_method, name.to_s.pluralize) { enum_values }
    :
    _enum_methods_module.module_eval do
      pairs = values.respond_to?(:each_pair) ? values.each_pair : values.each_with_index
        :
        value_method_name = "#{prefix}#{value}#{suffix}"
        enum_values[value] = i

        # def active?() status == 0 end
        define_method("#{value_method_name}?") { self[attr] == value.to_s }

        # def active!() update! status: :active end
        define_method("#{value_method_name}!") { update!(attr => value) }

        # scope :active, -> { where status: 0 }
        klass.scope value_method_name, -> { where(attr => value) }
      end
    end
    defined_enums[name.to_s] = enum_values
  end
end

rails/enum.rb at 21e5fd4a2a1c162ad33708d3e01b1fda165f204d · rails/rails · GitHub

順に処理を見てみます。まずは引数のdefinitionsですが、enum関数の実行時を見て分かるようにハッシュが束縛されています。

# 括弧が省略されているだけで enum({ kind: { nigiri: 0 ...}})と同じ
enum kind: { nigiri: 0, maki: 1, gunkan: 2, other: 99 }

関数の冒頭では_prefix_suffixがハッシュのキーに指定されていればハッシュから削除して、バリューを変数に束縛しています。どちらもenumに設定可能なオプションのようです。コードを見るまでenumにオプションがあることを全く知らなかったので、こうやってコードを見てみると新たな発見があるものです。

参考: Rails5 から enum 使う時は_prefix(接頭辞)_suffix(接尾辞)を使おう - Qiita

次にdefinitionsに対してeachを実行していきます。ここからが各種メソッドを定義している部分です。

self.statuses()

enumが定義されたクラスのsingleton_classに対してdefine_method関数を使用して関数を定義しています。pluralizeは単語を複数形にするRailsが提供するメソッドです。

klass.singleton_class.send(:define_method, name.to_s.pluralize) { enum_values }

この処理に実際に値が渡されると以下のようになります。現時点ではenum_valuesActiveSupport::HashWithIndifferentAccessのインスタンスが返るだけですが、後の処理でenum_valuesのキーバリューにしっかりと値を登録している(enum_values[value] = i)ので、問題なさそうです。

Sushi.singleton_class.send(:define_method, :kind.to_s.pluralize) { enum_values }

kinds関数が返すのはActiveSupport::HashWithIndifferentAccessのインスタンス。

Sushi.kinds.class
=> ActiveSupport::HashWithIndifferentAccess

ActiveSupport::HashWithIndifferentAccess.new({ nigiri: 0, maki: 1, gunkan: 2, other: 99 }).class
=> ActiveSupport::HashWithIndifferentAccess

active?()

同じようにdefine_method関数を呼び出していますが、こちらはsingleton_classへの定義ではないため、この関数はインスタンスメソッドになります。

define_method("#{value_method_name}?") { self[attr] == value.to_s }

例えばnigiri?が呼び出された時にはインスタンスのkind属性がnigiriと一致するかを判定する処理が実行されます。

# self[attr] == value.to_s
self[kind] == 'nigiri'

active!()とscope :active

どちらもほぼ同じです。
active!ではupdate!メソッドの呼び出し、scope :activeではスコープの作成を行なっています。

# active!
define_method("#{value_method_name}!") { update!(attr => value) }

# scope :active
klass.scope value_method_name, -> { where(attr => value) }

今回はすでに定義されているenumなのかなどの重複判定処理は簡単のため省略しましたが、やっていることはそんなに難しくなさそうです。これならオリジナルのenumが作れそうなので実際にオレオレenumを作ってみましょう。

オレオレenumの作成

まずはenum関数をモジュールに定義します。この時点ではただmoduleに定義されたenum関数です。

module MyEnum
  def enum(definitions)
  end
end

self.statuses()の実装

まずはself.statuses()の実装を行います。
definitionseach_pairメソッドを用いてキーバリューのペアを順に処理していきます。このMyEnumモジュールを使用したいクラスでextendして使ってもらうことを想定してself.singleton_classに対してdefine_method関数を呼び出します。ただ、Railsが提供するpluralizeメソッドは使えないため、今回はカラム名 + _hashの形式で呼び出せるようにします。

module MyEnum
  def enum(definitions)
    definitions.each_pair do |name, values|
      self.singleton_class.define_method("#{name}_hash") { values }
    end
  end
end

試しに適当なクラスを作成してextendして呼び出してみます。

class Sushi
  extend MyEnum
  
  enum kind: { nigiri: 0, maki: 1, gunkan: 2, other: 99 }
end

puts Sushi.kind_hash
# {:nigiri=>0, :maki=>1, :gunkan=>2, :other=>99}

無事にハッシュの取得ができました。

active?()の実装

代表してこのメソッドを作成します。同じようにdefine_method関数を呼び出しますが、こちらはインスタンスメソッドではないので、そのままdefine_method関数を呼び出します。この実装では制約としてextendしたクラス先でenumの引数に渡されるハッシュのキーとインスタンス変数の名前が一致する必要があります。

module MyEnum
  def enum(definitions)
    definitions.each_pair do |name, values|
      self.singleton_class.define_method("#{name}_hash") { values }
      
      values.each_pair do |key, value|
        instance_var_name = "@#{name}".to_sym
        define_method("#{key}?") { self.instance_variable_get(instance_var_name) == value }  
      end
    end
  end
end

動作を見てみましょう。確認のためにインスタンス変数とattr_readerを設定しています。

class Sushi
  extend MyEnum
  
  enum kind: { nigiri: 0, maki: 1, gunkan: 2, other: 99 }
  attr_reader :kind
  
  def initialize(kind:)
    @kind = kind
  end
end

s = Sushi.new(kind: 0)
puts s.nigiri? # true

良い感じです。複数のインスタンス変数を定義しても上手く動きました。

class Sushi
  extend MyEnum
  
  enum kind: { nigiri: 0, maki: 1, gunkan: 2, other: 99 }
  enum wasabi: { no_wasabi: 0, in_wasabi: 1 }
  attr_reader :kind, :wasabi
  
  def initialize(kind:, wasabi:)
    @kind = kind
    @wasabi = wasabi
  end
end

s = Sushi.new(kind: 0, wasabi: 1)
puts s.nigiri? # true
puts s.in_wasabi? # true

最後に

今回はRailsに定義された便利なenum関数について処理を追ってみました。
内容を省いた箇所はあるものの、蓋を開けてみるとそれほど難しいことはしておらずdefine_method関数を上手く利用していることが分かりました。MyEnumの作成にも成功しました。

過去にもdefine_methodが登場するコードを読んでいるのでぜひ、こちらもご覧ください。

www.okb-shelf.work

少しでも「ええな〜」と思ったらイイネ!・シェア!・はてなブックマークを頂けると励みになります。

参考文献