Rails 6.0 ⇒ 6.1アップグレード時にActiveModel::Errorsの仕様変更にハマったメモ

つい先週 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]
=> []

のように、動く。

detailsmessages という属性に見える通り、ハッシュのようにしてエラーの内容を持っている。

各エラーにアクセスするためには、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オブジェクトのリストとなった。

wheremessages_for を利用することによって、各エラーにアクセスすることができる。

もともとのハッシュのようなアクセスもインターフェースとしては6.1の段階では残っているが、PRによるとdeprecatedとなっているので、wheremessages_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\":[\"を入力してください\"]}"

errorsJSONとして APIレスポンスとしてまるっと返してしまっていると、APIのインターフェースが変わってしまう。

開発しているアプリケーションでは、key にたいする空配列が作成される前提のコードになっていたため、変更が求められた。

※その他に、errors[:hoge]でアクセスしていたところをmessages_forwhereに修正するという必要もあったが、その部分では動きは変わらなかったのでたいした問題にはならなかった。

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

というような感じで、headertableに分けてバリデーションしている。

まず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 keyerrorsに追加され、6.1では追加されなかった。

で、request spec には、header keyに空配列が入っていることを検証しようとしているのに、バージョンアップすればなくなる、といった問題だった。

結果、このプライベートAPIを利用しているフロントの実装は問題なかったので、そのままにし、RSpec を拡充してこの問題は終了した。

おわり

  • ActiveModel::Errors[]のインターフェースでアクセスしたときの挙動が変わっていることに気づくのに少し時間がかかった
  • Rails 6.1移行では、messages_forwhereなどを使ってActiveModel::Error オブジェクトにアクセスしましょう

*1:CSVテンプレートをダウンロードさせてあげることでフォーマットの誤り自体はある程度防ぐことができるとはおもう