pavlog

ウェブエンジニアがあれやこれやを書きます

Rails5.2: ActiveRecord::Relation で定義済のインスタンスメソッド名をenumで使うとRaiseする

f:id:paveg:20190125132826j:plain

こんにちは、駆け出しプログラマの pavです。
昨夜こんなツイートをしました。それについて調べたことを残しておきます。


Rails 5.1 -> 5.2へのアップデートによってエラーが発生しました。

出力されたエラーは下記。

ArgumentError: You tried to define an enum named "status" on the model "Request", but this will generate a class method "reject", which is already defined by ActiveRecord::Relation.
from /Users/ryota/src/github.com/paveg/rails_sample/vendor/bundle/gems/activerecord-5.2.2/lib/active_record/enum.rb:235:in `raise_conflict_error'

検証

Railsの最小限の検証環境を用意する

❯ mkdir ./rails_sample
❯ cd rails_sample
❯ bundle init
# railsのコメントアウトを外して、ついでに pryを入れる
# version差分を見るために、 5.1.6を入れてから5.2にアップデートします
❯ echo "gem 'pry'" >> Gemfile
❯ echo "gem 'pry-byebug'" >> Gemfile
❯ bundle install
❯ bundle exec rails new --database=mysql -B -M -P -C -S -J --skip-yarn --skip-coffee --skip-turbolinks .

モデルの作成


rails generate コマンドを使用してミニマムなモデルをつくります。
この時検証には、enumに ActiveRecord::Relation に定義済みの関数名を敢えて設定しておきます。

❯ rails g model Request name:string status:integer
      invoke  active_record
      create    db/migrate/20190124140752_create_requests.rb
      create    app/models/request.rb
      invoke    test_unit
      create      test/models/request_test.rb
      create      test/fixtures/requests.yml
class CreateRequests < ActiveRecord::Migration[5.1]
  def change
    create_table :requests do |t|
      t.string :name
      t.integer :status, null: false

      t.timestamps
    end
  end
end

# app/models/request.rb
class Request < ApplicationRecord
  enum status: { approve: 0, reject: 1 }
end
❯ rails db:migrate
== 20190124140752 CreateRequests: migrating ===================================
-- create_table(:requests)
   -> 0.0529s
== 20190124140752 CreateRequests: migrated (0.0530s) ==========================


rails consoleを立ち上げてみます(メソッド上書きしているけど立ち上がります)

❯ rails c
Loading development environment (Rails 5.1.6)
irb(main):001:0> pry # pryへ切り替え
[1] pry(main)> Request
=> Request (call 'Request.connection' to establish a connection)

無事立ち上がったので、Railsを5.2系へアップデートして先ほどと同様に rails consoleを立ち上げます。

❯ rails c
Loading development environment (Rails 5.2.2)
irb(main):001:0> pry
[1] pry(main)> Request
ArgumentError: You tried to define an enum named "status" on the model "Request", but this will generate a class method "reject", which is already defined by ActiveRecord::Relation.
from /Users/ryota/src/github.com/paveg/rails_sample/vendor/bundle/gems/activerecord-5.2.2/lib/active_record/enum.rb:235:in `raise_conflict_error'

なぜArgumentErrorが起こるのか


Scoping reserved names by kinnrot · Pull Request #31179 · rails/rails · GitHub のPRがマージされた為、Rails5.1では起こらなかったエラーが起こるようになりました。

Don't allow creating scopes named same as ActiveRecord::Relation instance method


ActiveRecord::Relationのインスタンスメソッドと同名のスコープの作成を許可しない修正です。
ご存知の通り、 `Enumerable#reject` は既に存在しています。

reject (Enumerable) - Rubyリファレンス


そして、 ActiveRecord::Relationでは、Enumerableモジュールがincludeされています

クラス呼び出し時の初期化の際に、 method_defined_within? を通ります。
既にEnumerableに予約済みの名前であるため、該当関数は真を返します。
結果として、 detect_enum_conflict! が呼び出され、内部でエラーがRaise します

      # rails/activerecord/lib/active_record/enum.rb
      def detect_enum_conflict!(enum_name, method_name, klass_method = false)
        if klass_method && dangerous_class_method?(method_name)
          raise_conflict_error(enum_name, method_name, type: "class")
>       elsif klass_method && method_defined_within?(method_name, Relation)
>         raise_conflict_error(enum_name, method_name, type: "class", source: Relation.name)
        elsif !klass_method && dangerous_attribute_method?(method_name)
          raise_conflict_error(enum_name, method_name)
        elsif !klass_method && method_defined_within?(method_name, _enum_methods_module, Module)
          raise_conflict_error(enum_name, method_name, source: "another enum")
        end
      end

...
      # rails/activerecord/lib/active_record/attribute_methods.rb
      def method_defined_within?(name, klass, superklass = klass.superclass) # :nodoc:
        if klass.method_defined?(name) || klass.private_method_defined?(name)
          if superklass.method_defined?(name) || superklass.private_method_defined?(name)
            klass.instance_method(name).owner != superklass.instance_method(name).owner
          else
            true
          end
        else
          false
        end
      end

...
      # rails/activerecord/lib/active_record/enum.rb
      def raise_conflict_error(enum_name, method_name, type: "instance", source: "Active Record")
        raise ArgumentError, ENUM_CONFLICT_MESSAGE % {
          enum: enum_name,
          klass: name,
          type: type,
          method: method_name,
          source: source
        }
      end

rails/enum.rb at master · rails/rails · GitHub

対策

予約語がかぶっちゃって辛いという人は、prefix/suffixを使って回避しましょう
サービスが小さければ気合いで変更しましょう

You can use the :_prefix or :_suffix options when you need to define multiple enums with same values. If the passed value is true, the methods are prefixed/suffixed with the name of the enum. It is also possible to supply a custom value:

class Conversation < ActiveRecord::Base
  enum status: [:active, :archived], _suffix: true
  enum comments_status: [:active, :inactive], _prefix: :comments
end

With the above example, the bang and predicate methods along with the associated scopes are now prefixed and/or suffixed accordingly:

conversation.active_status!
conversation.archived_status? # => false

conversation.comments_inactive!
conversation.comments_active? # => false

api.rubyonrails.org

まとめ

やっていき