Rails で複数のモデルを同時に更新するスマートな方法
Rails アプリケーションをクリーンに保つのに役立つ gem 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
class User < ApplicationRecord
has_one :profile
validates :email, presence: true
end
class Profile < ApplicationRecord
belongs_to :user
validates :display_name, :age, presence: true
end
一方で、システムのユーザー登録処理を担当するアクションとして、UserRegistrationsController#create
を定義します。
# 前後省略
resource :user_registration, only: %i[new create]
さらに、登録が正常に完了した後に、サンクスメールの通知を送信するようにします。
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
<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
には何も格納されません。
理想的には、email
、display_name
、age
のすべてのフィールドが必須であるべきです。
この場合、どれも入力されていない場合には、エラーの詳細が以下それぞれに格納されるべきです。
@user.errors[:email]
@profile.errors[:display_name]
@profile.errors[:age]
(そうでなければ、エラーメッセージをビューに表示できません。)
ある程度の柔軟性は許容できるかもしれませんが、厳密に言うと…
saved = [@user.save, @profile.save].all?
このように、短絡評価されないように気をつける必要があるかもしれません。
コントローラーとビューがモデルの詳細な構造に依存している
User
と Profile
は has_one
の関係を持っており、コントローラーはこの構造を認識しています。コントローラーでは、user.build_profile
を使用し、user_params
と profile_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
の記述が必要になりますが、コントローラでの属性に向けたアサインが簡単になります。
class User < ApplicationRecord
has_one :profile
validates :email, presence: true
+ accepts_nested_attributes_for :profile
end
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
- <%= fields_for :profile do |profile_form| %>
+ <%= form.fields_for :profile do |profile_form| %>
一見スリムになりますが、急に profile_attributes
というパラメータ名が出現するのは少々唐突です。
また、いずれにせよ view に表出している fields_for
は回避できません。
さらに、この場合はモデル間に has_one
や has_many
などの関連の記述が必須となります。これはそういった直接の関連のないモデルに対する更新には使えないことを意味します。
そして、 accepts_nested_attributes_for
の記述は言うならば view の都合での記述なのですがこれがモデルに出現するのは依存関係を考慮すると少々アンバランスです。
コントローラーは単一のモデルを扱うべき
上記のように、コントローラーとビューがモデルの構造を認識している状況は、複雑な処理につながる可能性があります。ここでは、単純な関係を持つ 2つのモデルで例を挙げていますが、さらにネストされた関係や、それらを一度に更新する必要がある場合を考えるととても大変です。
scaffold で生成されるコードから考えると、コントローラは単一のモデルを扱うときの記述はとても簡単なのでどのような場合もその世界を維持できると良いです。
フォームオブジェクト
一般的なパターンとして、この一連の処理をフォームオブジェクトに抽出する方法があります。
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
さらに、上記のフォームオブジェクトに基づいてコントローラーとビューを調整すると、以下のような例になります。
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
<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 %>
このフォームオブジェクトはあくまで一例ですが、通常は User
と Profile
を含み、#save
を使ってトランザクション内でそれらを更新します。 view に fields_for
も出現しません。
しかし、このようなフォームオブジェクトを作成したことがある人は、いくつかの考慮事項が関わることを知っています。例えば、モデルとフォームの両方に同じバリデーションを記述するのは冗長になることがあります。もしバリデーションがモデルにのみ定義されている場合、バリデーションエラーが発生したときに errors
をどのように構造化すべきでしょうか? ActiveModel に似たエクスペリエンスを目指すと、非常に手間がかかることが多いです。
複数のモデルを内包するモデルの設計
前述のように、(N) 個の ActiveRecord モデルを含むモデルを設計する際、そのオブジェクトが ActiveRecord に近しいエクスペリエンスを提供することが望ましいです。それが満たされていれば、scaffold によって生成されるコントローラーやビューのコードに非常に近い形で表現することができます。
具体的には、望ましい動作は以下のようになります:
- モデルは
#update(attributes)
を使って保存でき、その結果としてtrue
またはfalse
を返すべきです。 - 保存に失敗した場合、
#errors
にアクセスすることでその原因についての情報が得られるべきです。 - ビューで
form_with
のmodel
オプションにモデルを渡すことができるべきです。 - 上記を実現するために、
#to_param
、#to_key
、および#persisted?
のようなメソッドに応答できる必要があります。
さらに、複数のモデルを同時に更新できることを考慮すると、以下の動作も望まれます:
- データベースのトランザクション制御を使用して、複数のモデルをアトミックに更新できること。
-
after_commit
やafter_rollback
などの hook が定義可能であること。および内包するモデルのいずれかで処理が失敗した場合はafter_commit
が呼ばれることがないこと
-
- アトリビュートに透過的にアクセスできること。たとえば以下のような形
- モデル A にアトリビュート
attr_x
とattr_y
がある - モデル B にアトリビュート
attr_z
がある - モデル A およびモデル B を内包するモデルを設計するとき、以下のような操作がしたい
model.update(attr_x: 'foo', attr_y: 1, attr_z: 3.5)
- モデル A にアトリビュート
active_record_compose
gem active_record_compose
は、上記の課題を解決します。
gem 'active_record_compose'
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
上記で定義されたモデルを扱うコントローラーとビューは、以下のようになります。このコードは、User
と Profile
のリレーションシップについての知識を必要とせず、コントローラーやビューからモデルの構造を理解する必要もありません。まるで scaffold が生成したような構成です。
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
<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#email
と UserRegistration#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_commit
と after_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 を作りました。これの簡単な使い方の紹介でした。
-
Smart way to update multiple models simultaneously in Rails
- この記事の原文。本記事は原文のリライト&日本語訳です。
Discussion