つい先週 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
オブジェクトにアクセスしましょう