DEV Community

Takashi SAKAGUCHI
Takashi SAKAGUCHI

Posted on

Smart way to update multiple models simultaneously in Rails

I created a gem called active_record_compose, which helps keep your Rails application clean. Below is a brief guide on how to use it.
If you are interested, please use it.


complexity rails

Designing a resource that adheres to RESTful principles and creating CRUD operations for it is something that Rails excels at. You can see how easily this is expressed by looking at the controllers generated by the scaffold command.

However, things get a bit more complicated when you need to update multiple models simultaneously from a single controller. For example, below is an example of an action that updates both the User and Profile models at the same time.

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
Enter fullscreen mode Exit fullscreen mode
# app/models/user.rb
class User < ApplicationRecord
  has_one :profile
  validates :email, presence: true
end
Enter fullscreen mode Exit fullscreen mode
# app/models/profile.rb
class Profile < ApplicationRecord
  belongs_to :user
  validates :display_name, :age, presence: true
end
Enter fullscreen mode Exit fullscreen mode

On the other hand, let's define an action called UserRegistrationsController#create to handle the user registration process for the system. Additionally, once the registration is successfully completed, let's send a thank-you email notification.

# 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_now
      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
Enter fullscreen mode Exit fullscreen mode
<!-- app/views/user_registrations/new.html.erb -->
<% content_for :title, "New user registration" %>

<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 %>
Enter fullscreen mode Exit fullscreen mode

Although the above implementation works, there are some bugs and issues. Let's go over them.

Code Issues

Some errors Are Missing

Controller Code Excerpt

        saved = @user.save && @profile.save
Enter fullscreen mode Exit fullscreen mode

Both @user and @profile are being saved, but if @user's #save fails, @profile is not evaluated. As a result, nothing is stored in @profile.errors.

Image description

Ideally, all fields—email, display_name, and age —should be required. In this case, if none of them are filled in, the error details should be stored in @user.errors[:email], @profile.errors[:display_name], and @profile.errors[:age] respectively. (Otherwise, error messages cannot be displayed in the view.)

While some leniency may be acceptable, to be strict...

        saved = [@user.valid?, @profile.valid?].all? && @user.save && @profile.save
Enter fullscreen mode Exit fullscreen mode

It may require somewhat convoluted descriptions like this.

Image description

Controllers and Views Are Too Aware of the Model Structure Details

User and Profile have a has_one relationship, and the controller is aware of this structure. It uses user.build_profile and defines user_params and profile_params separately.

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

  def profile_params
    params.require(:profile).permit(:display_name, :age)
  end
Enter fullscreen mode Exit fullscreen mode

Additionally, the relationship between User and Profile is exposed in the view as well. The need to use fields_for indicates that there is a relationship between the models.

  <%= f.fields_for :profile do |profile_form| %>
Enter fullscreen mode Exit fullscreen mode

Controllers Should Handle a Single Model

As mentioned above, the situation where the controller and view are aware of the model structure can lead to complex processing. Here, we are illustrating with two models that have a simple relationship, but if we consider more nested relationships and the need to update them all at once in a single controller, it becomes clear why the controller can become complicated.

form object

A common pattern is to extract this series of processes into a form object.

# 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
Enter fullscreen mode Exit fullscreen mode

In addition, adjusting the controller and view based on the above form object would result in the following example.

# 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_now
      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
Enter fullscreen mode Exit fullscreen mode
<!-- app/views/user_registrations/show.html.erb -->
<% content_for :title, "New user registration" %>

<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 %>
Enter fullscreen mode Exit fullscreen mode

This form object is just one example, but it typically contains User and Profile and updates them within a transaction using #save.

However, those who have tried creating such form objects know that there are quite a few considerations involved. For instance, it can be redundant to write the same validations in both the model and the form. If validations are defined only in the model, how should the errors be structured when validation errors occur? When aiming for a user experience similar to ActiveModel, it often becomes quite cumbersome.

Designing a Model that Contains Other Models

As mentioned above, when designing a model that contains (N) ActiveRecord models, it is desirable for that object to have a user experience similar to ActiveRecord. If this requirement is met, it can be expressed in a way that closely resembles the code generated by the scaffold for controllers and views.

Specifically, the desired behavior would be as follows:

  • The model should be able to save using #update(attributes) and return the result as true or false.
  • If the save fails, accessing #errors should provide information about the cause.
  • It should be possible to pass the model to the model option of form_with in the view.
  • To achieve the above, it should respond to methods like #to_param, #to_key, and #persisted?.

Additionally, considering that multiple models can be updated simultaneously, the following behaviors are also desired:

  • With database transaction control, multiple models can be updated atomically.
  • When designing a model that encapsulates two models, A and B, for example, if there are attributes attribute_a in model A and attribute_b in model B, it should be possible to transparently access each attribute using model.assign_attributes(attribute_a: 'foo', attribute_b: 'bar').

active_record_compose

The gem active_record_compose resolves the challenges mentioned above.
https://github.com/hamajyotan/active_record_compose

# Gemfile
gem 'active_record_compose'
Enter fullscreen mode Exit fullscreen mode
# 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_now
end
Enter fullscreen mode Exit fullscreen mode

The controllers and views that handle the models defined above would look as follows. The code does not require knowledge of the relationship between User and Profile, nor does it require understanding of the models themselves from the controllers and views.

# 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
Enter fullscreen mode Exit fullscreen mode
<!-- app/views/user_registrations/new.html.erb -->
<% content_for :title, "New user registration" %>

<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 %>
Enter fullscreen mode Exit fullscreen mode

models collection

Let's take a look inside the definition of UserRegistration. The following code encapsulates the objects that will be saved simultaneously in the models. These objects are designed to be saved within a single database transaction when #save is executed.

    models << user << profile
Enter fullscreen mode Exit fullscreen mode

You can also write it as follows:

    models.push(user)
    models.push(profile)
Enter fullscreen mode Exit fullscreen mode

This may be a bit off-topic, but it can also handle cases where one model executes #save while another model executes #destroy.

    # User is saved and Profile is destroyed by executing UserRegistration#save.
    models.push(user)
    models.push(profile, destroy: true)
Enter fullscreen mode Exit fullscreen mode

If you want to execute destroy only under certain conditions and save otherwise, you can pass the method name as a symbol to make the determination, as shown below.

    # 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?
Enter fullscreen mode Exit fullscreen mode

delegate_attribute

delegate_attribute works similarly to Module#delegate defined in Active Support. In other words, it defines the methods UserRegistration#email and UserRegistration#email=, while delegating the implementation to user.

  delegate_attribute :email, to: :user
  delegate_attribute :display_name, :age, to: :profile
Enter fullscreen mode Exit fullscreen mode

Not only does it simply delegate, but when a validation error occurs, the content of the error is reflected in the 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"]
Enter fullscreen mode Exit fullscreen mode

Furthermore, the content is also reflected in #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}
Enter fullscreen mode Exit fullscreen mode

database transaction callback

ActiveRecordCompose::Model is fundamentally an ActiveModel::Model.

user_registration = UserRegistration.new
user_registration.is_a?(ActiveModel::Model)  #=> true
Enter fullscreen mode Exit fullscreen mode

However, it does more than that; it also supports transaction-related callbacks such as after_commit that are provided by ActiveRecord.

  after_commit :send_registered_mail
Enter fullscreen mode Exit fullscreen mode

Additionally, the after_commit and after_rollback callbacks in ActiveRecord behave as expected even when nested; that is, after_commit is triggered only when the entire transaction succeeds and is committed. The same behavior is defined in 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
Enter fullscreen mode Exit fullscreen mode
model = Wrapped.new(raise_error_flag: true)
model.save! rescue nil
# User#after_rollback
# Wrapped#after_rollback

model = Wrapped.new(raise_error_flag: false)
model.save! rescue nil
# User#after_commit
# Wrapped#after_commit
Enter fullscreen mode Exit fullscreen mode

Top comments (0)