ActiveRecord
module, developers can sometimes forget that, under the hood, our AR models are just regular Ruby objects. This allows us to make use of standard instance variables on the objects to adapt their behaviour, which can be useful in particular cases. In this post we discuss the technique and give an example of where it could be used.
Introduction
When introducing the Rails framework, many tutorials and articles will (understandably) rush to introduce the behaviours and API of the ActiveRecord::Base
class,
showing how ActiveRecord::Base
subclasses can be used to represent the models in your business domain, offering an automated object-relational mapping
for persisting these models to the database. The ActiveRecord::Base
class offers a rich API, [1],
covering a huge spectrum of behaviours for your data-modelling needs.
Indeed, the depth of this API is sometimes considered a drawback of the Rails active record (AR) implementation. The expansive API is arguably in violation of the single-responsibility principle, and makes it all too easy for developers to fall into the fat-model antipattern, [2], where all the application logic gets inappropriatedly accumulated in AR models, oftentimes culminating in the crytallization of so-called God objects, [4]. Various techniques have been discussed, [2, 3] to help you transition away from this fat-model anitipattern.
What is all-too-easily missed, when considering the funcationality of our AR models, is that they are just regular Ruby objects with embellished behaviours. That means we can use standard Ruby object functions to tailor our model behaviours. In this post we will look at one example which uses object instance variables for the win.
The full code outlined in this blog post can be found on GitHub, [5].
ActiveRecord
Domain Models
We consider the yawn-inducing blog application including User
and Post
classes, which are defined as follows:
# Composed of only :id and :name attributes
class User < ActiveRecord::Base
end
# Composed of attributes :id, :user_id (FK), :title and :body
class Post < ActiveRecord::Base
belongs_to :user
validates :user, :body, :title, presence: true
after_create :enrich_body
def copy
Post.skip_callback(:create, :after, :enrich_body)
Post.new(is_copy: true) do |post|
sleep 1 # Add a sleep to make things more interesting in the multi-threaded case
post.user = self.user
post.body = self.body
post.title = self.title
post.save!
end
ensure
Post.set_callback(:create, :after, :enrich_body)
end
private
def enrich_body
self.body += "\nAuthored by #{user.name}"
end
end
The User
model relies on the standard inheritied ActiveRecord::Base
behaviours, with attributes inferred from the users
table.
The model has only two attributes: id
and name
, nothing exciting.
The Post
model is a bit more interesting. It has an associated User
instance, representing the author of the Post
.
There is some validation, ensuring the presence of all required attributes and there is also an :after_create
hook which updates the body
of the post to simply add the author's name.
Beyond the standard ActiveRecord
behaviours we have also implemented a custom method to allow us to copy
a Post
instance.
If we simply create a new Post
instance with the attributes from the original post, we will hit a problem when we come to save the new model, because the
:after_create
will fire again and the body will get the author's name added for a second time. So to avoid this behaviour our copy
method makes use
of the skip_callback
method on the
ActiveRecord::Base
API.
So all is good until we try to run this code in a multi-threaded environment (like Puma or Sidekiq) and things start to go wrong. We can recreate the problem in our unit tests for the model:
require "test_helper"
class PostTest < ActiveSupport::TestCase
setup do
@user = User.create!(name: "Domhnall")
@attrs = {
user: @user,
title: "AR instance variables",
body: "Who'd have thunk it."
}
@post = Post.new(@attrs)
end
…
test "copy should return a new Post object" do
assert @post.copy.is_a?(Post)
assert_not_equal @post.copy.id, @post.id
end
test "copy should set post body to be equal to the original" do
assert_equal @post.copy.body, @post.body
end
test "copy should be thread-safe" do
n=2
(0...n).map do |i|
Thread.new do
puts "Thread #{i}"
post = Post.new({
user: @user,
title: "AR instance variables #{i}",
body: "Who'd have thunk it #{i}."
})
copy = post.copy
puts copy.body
assert_equal post.body, copy.body
end
end.each(&:join)
end
end
Whilst the earlier tests are hopefully self-explanatory, the last test may require some discussion. This test will set up n
separate threads, will build a new Post
within each thread and will then attempt to copy the newly instantiated Post
, finally it will assert that the post body is matching for the original and copied post.
When we run this test, things blow up as follows:
The reason for the error is reported as:
After create callback :enrich_body has not been defined (ArgumentError)
The reason for the failure is the fact that :skip_callback
is not thread-safe; the fact we are calling this on the Post
class rather than a Post
instance should probably raise alarm bells. We can verify that the problem is related to thread-safety because any value of n>1
will give rise to the same error, but
if n=1
(representing a single thread) the test suite passes without issue.
So how we solve this thread-safety issue?
Solution using regular instance variables
Our solution will be to make use of a simple instance variable, which we can set on the new object instance during the copy
operation
and which will control whether we should execute the after_create
hook. We will present the solution in full first, and then break it down a little:
# Composed of attributes :id, :user_id (FK), :title and :body
class Post < ActiveRecord::Base
attr_accessor :is_copy
belongs_to :user
validates :user, :body, :title, presence: true
after_create :enrich_body, unless: :is_copy
def copy
Post.new(is_copy: true) do |post|
sleep 1
post.user = self.user
post.body = self.body
post.title = self.title
post.save!
end
end
private
def enrich_body
self.body += "\nAuthored by #{user.name}"
end
end
We use the attr_accessor
method (see [6]) to define a new instance variable, is_copy
,
available to each Post
instance. This is a regular ruby instance variable, distinct from the typical attributes of our ActiveRecord
model, which map to database fields and are persisted to the database when the object is saved. By constrast, this instance variable will simply hold state
when the object is in memory, and this will not be persisted to the database. But that is precisely what we need in this case.
The is_copy
variable will hold a boolean value, and we will use this as a flag to indicate if the after_create
hook should be executed:
after_create :enrich_body, unless: :is_copy
We now have a simple boolean flag which we can set in our copy
operation, to indicate that that after_create
should not be
executed in this particular context. All that remains is for us to set the is_copy
flag as part of the copy
operation:
def copy
Post.new(is_copy: true) do |post|
sleep 1
post.user = self.user
post.body = self.body
post.title = self.title
post.save!
end
end
Again we only add the sleep
call here to make any multi-threading issues more apparent in our testing. With our new implementation the unit
tests pass without issue.:
Summary
The Rails ActiveRecord module offers a plethora of functionality for your domain modelling needs, but under the hood our AR models are just regular ruby objects. In some cases, this basic ruby object functionality is all we need to solve the problem in hand.
In this post we have outlined such an example. We examine a multi-threading issue which arises from our use of the ActiveRecord
method,
skip_callback
. The problem can be avoided by replacing this method with the judicious use of a regular instance variable on our ActiveRecord model.
As always, if you have any thoughts on this technique or any feedback on the post please share through the comments.
If you enjoyed this post, and you are interested in web technology and development, be sure to subscribe to our mailing list to receive an email notifications when we publish future posts.
References
- Rails docs on ActiveRecord::Base API
- Techniques to decompose fat models
- Airbrake blog post on the fat model antipattern
- Arkency blog post on God objects in Rails
- GitHub repo with source code for this blog post
- Post explaining use of
attr_accessor
Comments
There are no existing comments
Got your own view or feedback? Share it with us below …