🐫

Rails で複数のモデルを同時に更新するスマートな方法

に公開

Rails アプリケーションをクリーンに保つのに役立つ gem active_record_compose を作成しました。
以下は簡単なガイドです。興味があれば、ぜひ使ってみてください。
https://github.com/hamajyotan/active_record_compose

背景

複雑な Rails

RESTful な設計に基づいたリソースを定義し、それに対する CRUD 操作を作成することは、 Rails が得意とする分野です。 scaffold コマンドで生成されるコントローラーを見れば、そのシンプルさがよく分かります。
しかし、1つのコントローラーから複数のモデルを同時に更新する必要がある場合、少し複雑になります。
例えば、以下は User モデルと Profile モデルを同時に更新するアクションの例です。

create_table :users do |t|
  t.string :email, null: false
  t.timestamps
end

create_table :profiles do |t|
  t.references :user, index: { unique: true }, null: false
  t.string :display_name, null: false
  t.integer :age, null: false
  t.timestamps
end
app/models/user.rb
class User < ApplicationRecord
  has_one :profile
  validates :email, presence: true
end
app/models/profile.rb
class Profile < ApplicationRecord
  belongs_to :user
  validates :display_name, :age, presence: true
end

一方で、システムのユーザー登録処理を担当するアクションとして、UserRegistrationsController#create を定義します。

config/routes.rb
# 前後省略
  resource :user_registration, only: %i[new create]

さらに、登録が正常に完了した後に、サンクスメールの通知を送信するようにします。

app/controllers/user_registrations_controller.rb
class UserRegistrationsController < ApplicationController
  def new
    @user = User.new.tap { _1.build_profile }
  end

  def create
    @user = User.new(user_params)
    @profile = @user.build_profile(profile_params)

    result =
      ActiveRecord::Base.transaction do
        saved = @user.save && @profile.save
        raise ActiveRecord::Rollback unless saved
        true
      end

    if result
      UserMailer.with(user: @user).registered.deliver_later
      redirect_to user_registration_complete_path, notice: "registered."
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def user_params
    params.require(:user).permit(:email)
  end

  def profile_params
    params.require(:profile).permit(:display_name, :age)
  end
end
app/views/user_registrations/new.html.erb
<h1>New user registration</h1>

<%= form_with(model: @user, url: user_registration_path) do |form| %>
  <% if @user.errors.any? || @user.profile.errors.any? %>
    <div style="color: red">
      <h2>
        <%= pluralize(@user.errors.count + @user.profile.errors.count, "error") %>
        prohibited this user_registration from being saved:
      </h2>

      <ul>
        <% @user.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
        <% @user.profile.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= form.label :email, style: "display: block" %>
    <%= form.text_field :email %>
  </div>

  <%= fields_for :profile do |profile_form| %>
    <div>
      <%= profile_form.label :display_name, style: "display: block" %>
      <%= profile_form.text_field :display_name %>
    </div>
    <div>
      <%= profile_form.label :age, style: "display: block" %>
      <%= profile_form.number_field :age %>
    </div>
  <% end %>

  <div><%= form.submit %></div>
<% end %>

上記の実装は動作しますが、いくつかのバグや問題点があります、
それらを確認していきます。

先のコードの課題点

一部の errors が欠落している

コントローラのコード抜粋

        saved = @user.save && @profile.save

@user@profile の両方が保存されますが、@user#save が失敗した場合、@profile は評価されません。
その結果、@profile.errors には何も格納されません。

理想的には、emaildisplay_nameage のすべてのフィールドが必須であるべきです。
この場合、どれも入力されていない場合には、エラーの詳細が以下それぞれに格納されるべきです。

  • @user.errors[:email]
  • @profile.errors[:display_name]
  • @profile.errors[:age]

(そうでなければ、エラーメッセージをビューに表示できません。)

ある程度の柔軟性は許容できるかもしれませんが、厳密に言うと…

        saved = [@user.save, @profile.save].all?

このように、短絡評価されないように気をつける必要があるかもしれません。

コントローラーとビューがモデルの詳細な構造に依存している

UserProfilehas_one の関係を持っており、コントローラーはこの構造を認識しています。コントローラーでは、user.build_profile を使用し、user_paramsprofile_params を個別に定義しています。

  def new
    user = User.new.tap { _1.build_profile }
  def create
    @user = User.new(user_params)
    @profile = @user.build_profile(profile_params)
  def user_params
    params.require(:user).permit(:email)
  end

  def profile_params
    params.require(:profile).permit(:display_name, :age)
  end

また、このコントローラでの個別パラメータアサインですが、 view で fields_for でなく form.fields_for を使えば以下のように記述ができます。
User モデルに accepts_nested_attributes_for の記述が必要になりますが、コントローラでの属性に向けたアサインが簡単になります。

app/models/user.rb
 class User < ApplicationRecord
   has_one :profile
   validates :email, presence: true
+  accepts_nested_attributes_for :profile
 end
app/controllers/user_registrations_controller.rb
 class UserRegistrationsController < ApplicationController
   def new
     @user = User.new.tap { _1.build_profile }
   end

   def create
     @user = User.new(user_params)
-    @profile = @user.build_profile(profile_params)

-    result =
-      ActiveRecord::Base.transaction do
-        saved = @user.save && @profile.save
-        raise ActiveRecord::Rollback unless saved
-        true
-      end
-
-    if result
+    if @user.save
       UserMailer.with(user: @user).registered.deliver_later
       redirect_to user_registration_complete_path, notice: "registered."
     else
       render :new, status: :unprocessable_entity
     end
   end

   private

   def user_params
-    params.require(:user).permit(:email)
+    params.require(:user).permit(:email, profile_attributes: %i[display_name age])
   end
-
-  def profile_params
-    params.require(:user).permit(profile_attributes: %i[display_name age])
-  end
 end
app/views/user_registrations/new.html.erb
-  <%= fields_for :profile do |profile_form| %>
+  <%= form.fields_for :profile do |profile_form| %>

一見スリムになりますが、急に profile_attributes というパラメータ名が出現するのは少々唐突です。
また、いずれにせよ view に表出している fields_for は回避できません。

さらに、この場合はモデル間に has_onehas_many などの関連の記述が必須となります。これはそういった直接の関連のないモデルに対する更新には使えないことを意味します。
そして、 accepts_nested_attributes_for の記述は言うならば view の都合での記述なのですがこれがモデルに出現するのは依存関係を考慮すると少々アンバランスです。

コントローラーは単一のモデルを扱うべき

上記のように、コントローラーとビューがモデルの構造を認識している状況は、複雑な処理につながる可能性があります。ここでは、単純な関係を持つ 2つのモデルで例を挙げていますが、さらにネストされた関係や、それらを一度に更新する必要がある場合を考えるととても大変です。
scaffold で生成されるコードから考えると、コントローラは単一のモデルを扱うときの記述はとても簡単なのでどのような場合もその世界を維持できると良いです。

フォームオブジェクト

一般的なパターンとして、この一連の処理をフォームオブジェクトに抽出する方法があります。

app/models/user_registration.rb
class UserRegistration
  include ActiveModel::Model
  include ActiveModel::Validations::Callbacks
  include ActiveModel::Attributes

  attribute :email, :string
  attribute :display_name, :string
  attribute :age, :integer

  validates :email, presence: true
  validates :display_name, :age, presence: true

  def initialize(attributes = {})
    @user = User.new
    @profile = @user.build_profile
    super(attributes)
  end

  before_validation :assign_attributes_to_models

  def save
    return false if invalid?

    result =
      ActiveRecord::Base.transaction do
        user.save!
        profile.save!
        true
      end
    !!result
  end

  attr_reader :user, :profile

  private

  def assign_attributes_to_models
    user.email = email
    profile.display_name = display_name
    profile.age = age
  end
end

さらに、上記のフォームオブジェクトに基づいてコントローラーとビューを調整すると、以下のような例になります。

app/controllers/user_registrations_contrller.rb
class UserRegistrationsController < ApplicationController
  def new
    @user_registration = UserRegistration.new
  end

  def create
    @user_registration = UserRegistration.new(user_registration_params)

    if @user_registration.save
      UserMailer.with(user: @user_registration.user).registered.deliver_later
      redirect_to user_registration_complete_path, notice: "registered."
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def user_registration_params
    params.require(:user_registration).permit(:email, :display_name, :age)
  end
end
app/views/user_registrations/new.html.erb
<h1>New user registration</h1>

<%= form_with(model: @user_registration, url: user_registration_path) do |form| %>
  <% if @user_registration.errors.any? %>
    <div style="color: red">
      <h2>
        <%= pluralize(@user_registration.errors.count, "error") %>
        prohibited this user_registration from being saved:
      </h2>

      <ul>
        <% @user_registration.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= form.label :email, style: "display: block" %>
    <%= form.text_field :email %>
  </div>
  <div>
    <%= form.label :display_name, style: "display: block" %>
    <%= form.text_field :display_name %>
  </div>
  <div>
    <%= form.label :age, style: "display: block" %>
    <%= form.number_field :age %>
  </div>

  <div><%= form.submit %></div>
<% end %>

このフォームオブジェクトはあくまで一例ですが、通常は UserProfile を含み、#save を使ってトランザクション内でそれらを更新します。 view に fields_for も出現しません。

しかし、このようなフォームオブジェクトを作成したことがある人は、いくつかの考慮事項が関わることを知っています。例えば、モデルとフォームの両方に同じバリデーションを記述するのは冗長になることがあります。もしバリデーションがモデルにのみ定義されている場合、バリデーションエラーが発生したときに errors をどのように構造化すべきでしょうか? ActiveModel に似たエクスペリエンスを目指すと、非常に手間がかかることが多いです。

複数のモデルを内包するモデルの設計

前述のように、(N) 個の ActiveRecord モデルを含むモデルを設計する際、そのオブジェクトが ActiveRecord に近しいエクスペリエンスを提供することが望ましいです。それが満たされていれば、scaffold によって生成されるコントローラーやビューのコードに非常に近い形で表現することができます。

具体的には、望ましい動作は以下のようになります:

  • モデルは #update(attributes) を使って保存でき、その結果として true または false を返すべきです。
  • 保存に失敗した場合、#errors にアクセスすることでその原因についての情報が得られるべきです。
  • ビューで form_withmodel オプションにモデルを渡すことができるべきです。
  • 上記を実現するために、#to_param#to_key、および #persisted? のようなメソッドに応答できる必要があります。

さらに、複数のモデルを同時に更新できることを考慮すると、以下の動作も望まれます:

  • データベースのトランザクション制御を使用して、複数のモデルをアトミックに更新できること。
    • after_commitafter_rollback などの hook が定義可能であること。および内包するモデルのいずれかで処理が失敗した場合は after_commit が呼ばれることがないこと
  • アトリビュートに透過的にアクセスできること。たとえば以下のような形
    • モデル A にアトリビュート attr_xattr_y がある
    • モデル B にアトリビュート attr_z がある
    • モデル A およびモデル B を内包するモデルを設計するとき、以下のような操作がしたい
      model.update(attr_x: 'foo', attr_y: 1, attr_z: 3.5)

active_record_compose

gem active_record_compose は、上記の課題を解決します。
https://github.com/hamajyotan/active_record_compose

Gemfile
gem 'active_record_compose'
app/models/user_registration.rb
class UserRegistration < ActiveRecordCompose::Model
  def initialize(attributes = {})
    @user = User.new
    @profile = @user.build_profile
    models << user << profile
    super(attributes)
  end

  delegate_attribute :email, to: :user
  delegate_attribute :display_name, :age, to: :profile

  after_commit :send_registered_mail

  private

  attr_reader :user, :profile

  def send_registered_mail = UserMailer.with(user:).registered.deliver_later
end

上記で定義されたモデルを扱うコントローラーとビューは、以下のようになります。このコードは、UserProfile のリレーションシップについての知識を必要とせず、コントローラーやビューからモデルの構造を理解する必要もありません。まるで scaffold が生成したような構成です。

app/controllers/user_registrations_controller.rb
class UserRegistrationsController < ApplicationController
  def new
    @user_registration = UserRegistration.new
  end

  def create
    @user_registration = UserRegistration.new(user_registration_params)

    if @user_registration.save
      redirect_to user_registration_complete_path, notice: "registered."
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def user_registration_params
    params.require(:user_registration).permit(:email, :display_name, :age)
  end
end
app/views/user_registrations/new.html.erb
<h1>New user registration</h1>

<%= form_with(model: @user_registration, url: user_registration_path) do |form| %>
  <% if @user_registration.errors.any? %>
    <div style="color: red">
      <h2>
        <%= pluralize(@user_registration.errors.count, "error") %>
        prohibited this user_registration from being saved:
      </h2>

      <ul>
        <% @user_registration.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= form.label :email, style: "display: block" %>
    <%= form.text_field :email %>
  </div>
  <div>
    <%= form.label :display_name, style: "display: block" %>
    <%= form.text_field :display_name %>
  </div>
  <div>
    <%= form.label :age, style: "display: block" %>
    <%= form.number_field :age %>
  </div>

  <div><%= form.submit %></div>
<% end %>

models collection

UserRegistration の定義を見てみましょう。以下のコードは、models 内で同時に保存されるオブジェクトをカプセル化しています。これらのオブジェクトは、#save が実行されるときに、単一のデータベーストランザクション内で保存されるように設計されています。

    models << user << profile

次のように書くこともできます:

    models.push(user)
    models.push(profile)

少し話がそれてますが、これは一方のモデルが #save を実行し、もう一方のモデルが #destroy を実行する場合にも対応できます。

    # UserRegistration#save を実行することで User は保存され、Profile は破棄されます
    models.push(user)
    models.push(profile, destroy: true)

特定の条件下でのみ destroy を実行し、それ以外の場合は save を実行したい場合、メソッド名をシンボルとして渡して判断を行うことができます。以下のように記述できます。

    # User is saved and Profile is destroyed by executing UserRegistration#save.
    models.push(user)
    models.push(profile, destroy: :profile_field_is_blank?)
    # ...

  private

  def profile_field_is_blank? = display_name.blank? && age.blank?

delegate_attribute

delegate_attribute は、Active Support で定義されている Module#delegate と似た動作をします。言い換えれば、UserRegistration#emailUserRegistration#email= のメソッドを定義し、その実装を user に委譲します。

  delegate_attribute :email, to: :user
  delegate_attribute :display_name, :age, to: :profile

単に委譲するだけでなく、バリデーションエラーが発生した場合、そのエラーの内容が errors に反映されます。

user_registration = UserRegistration.new(email: nil, display_name: nil, age: 18)
user_registration.valid?  #=> false
user_registration.errors.to_a
=> ["Email can't be blank", "Display name can't be blank"]

さらに、その内容は #attributes にも反映されます。

user_registration = UserRegistration.new(
  email: 'foo@example.com',
  display_name: 'foo',
  age: 18
)
user_registration.attributes
#=> {
  "email" => "foo@example.com",
  "display_name" => "foo",
  "age" => 18
}

database transaction callback

ActiveRecordCompose::Model は基本的に ActiveModel::Model です。

user_registration = UserRegistration.new
user_registration.is_a?(ActiveModel::Model)  #=> true

しかし、それだけではなく、ActiveRecord によって提供される after_commit などのトランザクション関連のコールバックにも対応しています。

  after_commit :send_registered_mail

さらに、ActiveRecord の after_commitafter_rollback コールバックは、ネストされた場合でも期待通りに動作します。つまり、after_commit は、トランザクション全体が成功してコミットされたときのみ発火します。同じ動作が ActiveRecordCompose::Model にも定義されています。

class User < ApplicationRecord
  after_commit -> { puts 'User#after_commit' }
  after_rollback -> { puts 'User#after_rollback' }
end

class Wrapped < ActiveRecordCompose::Model
  attribute :raise_error_flag, :boolean, default: false

  def initialize(attributes = {})
    super(attributes)
    models << User.new
  end

  after_save ->(model) { raise 'not saved!' if model.raise_error_flag }
  after_commit -> { puts 'Wrapped#after_commit' }
  after_rollback -> { puts 'Wrapped#after_rollback' }
end
# 保存に失敗する場合は rollback hook が発火する
model = Wrapped.new(raise_error_flag: true)
model.save! rescue nil
# User#after_rollback
# Wrapped#after_rollback

# 保存に成功する場合は commit hook が発火する
model = Wrapped.new(raise_error_flag: false)
model.save! rescue nil
# User#after_commit
# Wrapped#after_commit

まとめ

  • Rails において、1つのコントローラ(アクション) から複数のモデルに対する更新操作は複雑になりがちです。
  • それを解決するために active_record_compose という gem を作りました。これの簡単な使い方の紹介でした。

Discussion