Rails5.2: ActiveRecord::Relation で定義済のインスタンスメソッド名をenumで使うとRaiseする
こんにちは、駆け出しプログラマの pavです。
昨夜こんなツイートをしました。それについて調べたことを残しておきます。
Rails5.2からEnumerableモジュールの関数名をクラスのenumに定義するとArgumentErrorで死ぬっぽい
— pav (@_pavlog) 2019年1月24日
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
対策
予約語がかぶっちゃって辛いという人は、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 endWith 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
まとめ
やっていき