Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: :before_validationコールバックの逸脱した用法を改善する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

なお、原文のbefore_validateは訳文でbefore_validationに修正しました。

  • 2017/12/12: 初版公開
  • 2022/10/04: 更新

Rails: :before_validationコールバックの逸脱した用法を改善する(翻訳)

ActiveRecordのコールバックが多くのプロジェクトで乱用され、Service Objectなどのずっと優れた方法なら簡単に回避できるユースケースでも誤った理由で使われているのは今に始まったことではありません。実行される処理と関係のない、かなり逸脱した理由で多用される特殊なコールバックが1つあります。それがbefore_validationコールバックです。

データ整形

データ整形、特に文字列のストリップは、アプリの主要な部分を占めることが多い処理です。たとえば、スペース文字で問題が生じないように URLをストリップすることを考えてみましょう。どのようなアプローチが考えられるでしょうか。

1つの方法は、before_validationを使うことです。特にデータ形式のバリデーションを行っている場合です。

# app/models/my_model.rb
class MyModel
  before_validation :strip_url

  private

  def strip_url
    self.url = url.to_s.strip
  end
end

これでも動きますが、これをどうテストすればよいでしょうか。そのモデルでvalid?メソッドを呼び、URLがストリップされたかどうかをチェックする必要があるでしょうか?これはいかにも不自然です。これに対応するであろう以下のspecを見てみれば違和感がもっとよくわかるでしょう。

# spec/models/my_model_spec.rb
require "rails_helper"

RSpec.describe MyModel, type: :model do
  it "バリデーション前にURLをストリップする" do
    model = MyModel.new(url: "  http://rubyonrails.org")

    model.valid?

    expect(model.url).to eq "http://rubyonrails.org"
  end
end

このコードがTDDの結果とはちょっと考えにくいですね。では他に方法はあるでしょうか。

次のように、単に専用の属性ライターメソッド(#url=)を書く方法ならどうでしょう。

# app/models/my_model.rb
class MyModel
  def url=(val)
    super(val.to_s.strip)
  end
end

この機能に対応するspecとして次が考えられます。

# spec/models/my_model_spec.rb
require "rails_helper"

RSpec.describe MyModel, type: :model do
  it "strips URL" do
    model = MyModel.new(url: "  http://rubyonrails.org")

    expect(model.url).to eq "http://rubyonrails.org"
  end
end

これなら実装もspecもずっとシンプルになりますし、ずっと自然です。データ整形はバリデーションと何の関係もないので、このようなユースケースを扱うためにバリデーションがらみのコールバックを使う必要はありません。

属性やリレーションシップを代入する

もうひとつのよくあるシナリオは、属性やリレーションシップの代入です。たとえば、contentを1個持つコメントと、current_userになる著者(author)を作成し、かつパフォーマンス上の理由から何らかの非正規化(denormalization)を行って、current_userが属するコメントにgroupを直接代入したいとします。以下はbefore_validationコールバックを少し使った例です。

Comment.create!(
  content: content,
  author: current_user,
)
# app/models/my_model.rb
class MyModel
  before_validation :assign_group

  private

  def assign_group
    self.group = author.group if author
  end
end

これも先のデータ整形のユースケースとかなり似ています。この機能のテストを書くためにvalid?を呼ぶ必要があるでしょうか?バリデーションは、属性やリレーションシップの代入とは何の関係もないのですから、あまり必然性を感じられません。これを次のように書けば、ずっとシンプルで明示的な方法で扱えるようになります。

Comment.create!(
  content: content,
  author: current_user,
  group: current_user.group,
)

単なる代入なのでマジックは不要ですし、テストも理解も簡単です。

まとめ

どうしてもbefore_validationコールバックがベストの選択になることがもしかするとあるかもしれません(私はそのような状況に出会ったことがありませんが)。しかし私は、データの整形や属性・関連付けの代入は、before_validationコールバックに適していないとかなりの程度確信しています。

関連記事

RailsのObject#tryがダメな理由と効果的な代替手段(翻訳)

3年以上かけて培ったRails開発のコツ集大成(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。