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
# 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
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]
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
<!-- 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 %>
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
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
.
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?
Thus, we may need to be careful not to be shortchanged in our evaluation.
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 }
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
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
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| %>
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
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
<!-- 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 %>
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 ofform_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 andattribute_b
in model B, it should be possible to transparently access each attribute usingmodel.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'
# 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
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
<!-- 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 %>
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
You can also write it as follows:
models.push(user) models.push(profile)
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)
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?
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
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"]
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}
database transaction callback
ActiveRecordCompose::Model
is fundamentally an ActiveModel::Model
.
user_registration = UserRegistration.new user_registration.is_a?(ActiveModel::Model) #=> true
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
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
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
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)
Very interesting bullet. Thanks for sharing I may need to compose models in one of my project !!
Thank you for your comment. I’d be happy if you could try using this gem !