ActiveRecord::Enumで陥りがちなミスを避ける

  • 10
    Like
  • 0
    Comment
More than 1 year has passed since last update.

ActiveRecord::Enumという機能がRails4.1から入りました。
気軽に使えるので、割りと積極的に使っているのですがその中である問題が発生しました。

ActiveRecord::Enumの提供してくれる標準で提供してくれるインターフェイスだけでは*1、whereの引数で指定する値は実際にSQLのWHERE句に指定されるものが渡すため本来は文字列が使えないが、何となく使えそうなので渡してみると、値が0として動作してしまう、という問題です :cold_sweat:

以下、例です。NotifyEndpointというPush通知の送り先を永続化するテーブルについて考えた場合、platformとしてiPhone用のAPNSとAndroid用のGCMなどのプラットフォームをenumで指定してみます。

class NotifyEndpoint
  enum platform: [:apns, :gcm]
end
# OK: newやcreateでは渡せる
endpoint = NotifyEndpoint.new(platform: :apns)

# OK: platform=メソッドにも渡せる
endpoint.platform = :gcm

# OK: apnsというスコープは定義されている
endpoints = NotifyEndpoint.apns

# NG: whereに文字列やシンボルを渡しても正しく動作しない
#     しかしエラーが出ずに0としてSELECTされる!!
endpoints = NotifyEndpoint.where(platform: :gcm)
#=> SELECT * FROM notify_endoints WHERE platform = 0;

# OK: NotifyEndpoint.platformsから、enumの序数を取得してwhereで指定する
endpoints = NotifyEndpoint.where(platform: NotifyEndpoint.platforms[:gcm])
#=> SELECT * FROM notify_endoints WHERE platform = 1;

というように、基本的には文字列シンボルでenumを扱うことが出来るんですが、標準で定義されるスコープ以外で、SELECTしたいときは要注意です。whereにはenumの要素の名前を受け付けてくれないのですがエラーが出ないのでやっかいです。正常ケースのテストが1個目の要素しかテストしてないと普通に通るのでうっかりバグを入れてしまいます :imp:

かと言って、毎回NotifyEndpoint.where(platform: NotifyEndpoint.platforms[:gcm])のような記述方法をするのはクラス名2回書かなきゃならないし、enumの序数についていちいち意識しなきゃいけないのは、面倒くさいです。

↑の問題を回避しつつ、面倒くさくない記法が出来るようenum名でscopeを生やしてくれるパッチを書きました :tada: lib/active_record/enum/scoping.rbなどに置いてconfig/initializers/*.rbのどこかでrequireしておけば使えるかと思います。

if defined?(ActiveRecord) &&  defined?(::ActiveRecord::Enum)
  module ActiveRecord
    module Enum
      module Scoping
        def self.extended(base)
          ::ActiveRecord::Enum.alias_method_chain(:enum, :scoping)
        end
      end

      def enum_with_scoping(definitions)
        enum_without_scoping(definitions)
        define_scoping_method(definitions)
      end

      private
      def define_scoping_method(definitions)
        definitions.each do |name, values|
          scoping_method_name =  "#{name}_as".to_sym
          scope(scoping_method_name, - > (item) { enum_scope(name, item) }})
        end
      end
    end

    def enum_scope(enum_name, item_name)
      query_hash =  {}
      query_hash[enum_name.to_sym] =  send(enum_name.to_s.pluralize)[item_name]
      where(query_hash)
    end
  end

  ::ActiveRecord::Base.extend(::ActiveRecord::Enum::Scoping)
end

これを導入すると以下のように、<enum_name>_asというスコープが定義されるので、文字列・シンボルを渡しても動作するようになりましたし、いちいちクラスメソッドで値を変換したり、自前で毎回スコープを定義したりせず済むようになりました :ok_hand:

endpoints = NotifyEndpoint.platform_as(:gcm)
#=> SELECT * FROM notify_endpoints WHERE platform = 1;

需要があればgemにしてみます。