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. 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
<!-- 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.valid?, @profile.valid?].all? && @user.save && @profile.save
It may require somewhat convoluted descriptions like this.
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, 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| %>
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/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 %>
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
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 !