つい先週 Rails 6.0系だったアプリケーションを 6.1にアップグレードした。
6.0系で rails new しているもので、テストカバレッジもそこそこあるので、アップグレード自体は半日もかからず終わったが、ActiveModel::Errorsの仕様の変更によって修正が必要な部分があり、その原因が分かりづらかったのでメモ。
ActiveModel::Errors の変更内容
Rails ガイドの記述: https://railsguides.jp/6_1_release_notes.html#active-model-主な変更
PR: https://github.com/rails/rails/pull/32313
例えば、以下のようなモデルクラスがあるとして:
class Dog include ActiveModel::Validations validates :name, presence: true validates :age, numericality: { only_integer: true }, allow_nil: true attr_reader :name, :age def initialize(name: nil, age: nil) @name = name @age = age end end
Rails 6.0系では:
Loading development environment (Rails 6.0.3.4) > dog = Dog.new(age: 3) => #<Dog:0x0000559792f03f50 @age=3, @name=nil> > dog.valid? => false > dog.errors => #<ActiveModel::Errors:0x000055978faa7338 @base= #<Dog:0x0000559792f03f50 @age=3, @errors=#<ActiveModel::Errors:0x000055978faa7338 ...>, @name=nil, @validation_context=nil>, @details={:name=>[{:error=>:blank}]}, @messages={:name=>["を入力してください"]}> > dog.errors[:name] => ["を入力してください"] > dog.errors[:age] => []
のように、動く。
details と messages という属性に見える通り、ハッシュのようにしてエラーの内容を持っている。
各エラーにアクセスするためには、ActiveModel::Errors のインスタンスにたいしてハッシュの値を取得するようにしてアクセスする必要があった。
Rails 6.1系では:
Loading development environment (Rails 6.1.1) > dog = Dog.new(age: 3) => #<Dog:0x00007f1745a30008 @age=3, @name=nil> > dog.valid? => false > dog.errors => #<ActiveModel::Errors:0x00007f17459f2c58 @base= #<Dog:0x00007f1745a30008 @age=3, @errors=#<ActiveModel::Errors:0x00007f17459f2c58 ...>, @name=nil, @validation_context=nil>, @errors=[#<ActiveModel::Error attribute=name, type=blank, options={}>]> > dog.errors.where(:name) => [#<ActiveModel::Error attribute=name, type=blank, options={}>] > dog.errors.where(:name).first.message => "を入力してください" > dog.errors.where(:age) => [] > dog.errors.messages_for(:name) => ["を入力してください"] > dog.errors.messages_for(:age) => []
各エラーは、ActiveModel::Errorオブジェクトとして表現され、ActiveModel::Errors は その Errorオブジェクトのリストとなった。
where、messages_for を利用することによって、各エラーにアクセスすることができる。
もともとのハッシュのようなアクセスもインターフェースとしては6.1の段階では残っているが、PRによるとdeprecatedとなっているので、whereやmessages_forなどにおきかえていくのが良いと思う。
変更が必要だった点
6.0系では、[]インターフェースで各エラーにアクセスしたときに、空のエラーが追加されていた。
例えば、
Loading development environment (Rails 6.0.3.4) > dog = Dog.new(age: 3) => #<Dog:0x0000559792f03f50 @age=3, @name=nil> > dog.valid? => false > dog.errors => #<ActiveModel::Errors:0x000055978faa7338 @base= #<Dog:0x0000559792f03f50 @age=3, @errors=#<ActiveModel::Errors:0x000055978faa7338 ...>, @name=nil, @validation_context=nil>, @details={:name=>[{:error=>:blank}]}, @messages={:name=>["を入力してください"]}> > dog.errors[:name] => ["を入力してください"] > dog.errors[:age] => [] > dog.errors => #<ActiveModel::Errors:0x0000561e22f62890 @base= #<Dog:0x0000561e22fa7c88 @age=3, @errors=#<ActiveModel::Errors:0x0000561e22f62890 ...>, @name=nil, @validation_context=nil>, @details={:name=>[{:error=>:blank}]}, @messages={:name=>["を入力してください"], :age=>[]}> > dog.errors.to_json => "{\"name\":[\"を入力してください\"],\"age\":[]}"
一度dog.errors[:age]にアクセスすると、ageにエラーはないにも関わらず、age keyが存在する形になる。
一方で、6.1系では[]でアクセスしても、error のリストや、errors.to_jsonの結果に変更はない。
Loading development environment (Rails 6.1.1) > dog = Dog.new(age: 3) => #<Dog:0x00007f1745a30008 @age=3, @name=nil> > dog.valid? => false > dog.errors => #<ActiveModel::Errors:0x00007f17459f2c58 @base= #<Dog:0x00007f1745a30008 @age=3, @errors=#<ActiveModel::Errors:0x00007f17459f2c58 ...>, @name=nil, @validation_context=nil>, @errors=[#<ActiveModel::Error attribute=name, type=blank, options={}>]> > dog.errors[:name] => ["を入力してください"] > dog.errors[:age] => [] > dog.errors => #<ActiveModel::Errors:0x000055f4b47046e0 @base= #<Dog:0x000055f4b474d570 @age=3, @errors=#<ActiveModel::Errors:0x000055f4b47046e0 ...>, @name=nil, @validation_context=nil>, @errors=[#<ActiveModel::Error attribute=name, type=blank, options={}>]> > dog.errors.to_json => "{\"name\":[\"を入力してください\"]}"
errors を JSONとして APIレスポンスとしてまるっと返してしまっていると、APIのインターフェースが変わってしまう。
開発しているアプリケーションでは、key にたいする空配列が作成される前提のコードになっていたため、変更が求められた。
※その他に、errors[:hoge]でアクセスしていたところをmessages_for やwhereに修正するという必要もあったが、その部分では動きは変わらなかったのでたいした問題にはならなかった。
CSVのバリデーションとエラーレスポンス
ここから、僕が具体的に遭遇したケースの話を書く。
ユーザーがアップロードしたCSVを利用して、手で作るのは辛い大量のデータを作成する機能を実装するのはよくある。
そういう機能を実装するときに、フォーマットや各項目のバリデーションをするのに、AcriveModel::Validationsを利用していて、
class BaseCsv include ActiveModel::Validations attr_accessor :header, :table def initialize(csv_string) @table = ::CSV.new(csv_string, headers: true).read @header = ::CSV.parse(csv_string).first end # # 各種バリデーションロジック # end
というような感じで、headerとtableに分けてバリデーションしている。
まずheaderで、各列ごとの要素や順番、列の数など、CSVの大まかな形を検証し、仕様通りのフォーマットでアップロードされたCSVかを判断し、
tableの方のロジックで、1つずつの値をバリデーションしていく。
すると、
{ "header": [], "table": [ "[2 行目] 他の メールアドレス と重複しています。", "[4 行目] 名前 は 20 文字以内で指定してください。", "[5 行目] 名前 は 191 文字以内で指定してください。", "[7 行目] 名前 が空です。入力してください。", "[8 行目] メールアドレスのフォーマットが不正です。", "[9 行目] 店舗名 が空です。入力してください。" ] }
という形で、keyはちょっと分かりづらい感があるが、フォーマット と 各項目によってエラーを分けることができる。
フォーマットが誤っているときは、各項目が空になったり、要素がおかしかったりして、全行に渡って大量にメッセージが入ってしまうため、フォーマットをきれいに直してもらってから、中身の検証をしてあげよう、ということになっている。*1
名前,メールアドレスなCSVがほしいときに、仮に名前列しかなかった場合、
{ "header": [ "CSV は 2 列で指定してください。", "2 列目は 名前 を指定してください。" ], "table": [ "[2 行目] 名前 が空です。入力してください。", "[3 行目] 名前 が空です。入力してください。", "[4 行目] 名前 が空です。入力してください。", ........ "[2000 行目] メールアドレス が空です。入力してください。" ] }
みたいなことになりかねないので、フォーマットのエラーだけを先に見て、問題なければ中身を見る、ということにしている。
csv.errors[:header].present?
というのを聞けばよいだけだが、こうすると、6.0ではheader key がerrorsに追加され、6.1では追加されなかった。
で、request spec には、header keyに空配列が入っていることを検証しようとしているのに、バージョンアップすればなくなる、といった問題だった。
結果、このプライベートAPIを利用しているフロントの実装は問題なかったので、そのままにし、RSpec を拡充してこの問題は終了した。
おわり
ActiveModel::Errorsに[]のインターフェースでアクセスしたときの挙動が変わっていることに気づくのに少し時間がかかった- Rails 6.1移行では、
messages_forやwhereなどを使ってActiveModel::Errorオブジェクトにアクセスしましょう













