DEV Community

Takashi SAKAGUCHI
Takashi SAKAGUCHI

Posted on • Edited 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.

# config/routes.rb # Omit the previous and following. resource :user_registration, only: %i[new create] 
Enter fullscreen mode Exit fullscreen mode

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_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 
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.

All attributes are empty, but an error message exists only for the email address.

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.save, @profile.save].all? 
Enter fullscreen mode Exit fullscreen mode

Thus, we may need to be careful not to be shortchanged in our evaluation.

With the above fix, error messages are now displayed for all attributes.

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, when assigning individual parameters in this controller, you can write it as follows by using form.fields_for instead of fields_for in the view.

The accepts_nested_attributes_for declaration is required in the User model, but it simplifies attribute assignment in the controller.

app/models/user.rb

 class User < ApplicationRecord has_one :profile validates :email, presence: true + accepts_nested_attributes_for :profile  end 
Enter fullscreen mode Exit fullscreen mode

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

app/views/user_registrations/new.html.erb

- <%= fields_for :profile do |profile_form| %> + <%= form.fields_for :profile do |profile_form| %> 
Enter fullscreen mode Exit fullscreen mode

At first glance, it looks more streamlined, but parameters like profile_attributes suddenly appear.
Additionally, fields_for, which is exposed in the view, cannot be avoided in any case.
Furthermore, in this approach, associations such as has_one or has_many must be explicitly defined between models. This means it cannot be used for updating models that do not have a direct association.

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/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

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

Summary

  • In Rails, updating multiple models from a single controller action tends to be complex.
  • To address this, I created a gem called active_record_compose. This was a brief introduction to its usage.

Top comments (2)

Collapse
 
just-the-v profile image
Just The V

Very interesting bullet. Thanks for sharing I may need to compose models in one of my project !!

Collapse
 
hamajyotan profile image
Takashi SAKAGUCHI

Thank you for your comment. I’d be happy if you could try using this gem !