I recently started a new project with Rails 5, and at some point I found out the code was not behaving as expected.
The initial scenario was quite simple, Admins can have many Phones, as this code suggests:
class Admin < ApplicationRecord
has_many :phones
end
class Phone < ApplicationRecord
belongs_to :admin
end
New requirements add new issues
Then application requirements changed as follows:
“Admins can add/remove phones, but are not allowed to destroy the last remaining one.”
After reading the requirements, my first guess was to use a before_destroy
callback in the Phone model. If the callback returns false, the destroy operation should be halted:
class Phone < ApplicationRecord
belongs_to :admin
before_destroy :dont_destroy_the_last_admin_phone
private
def dont_destroy_the_last_admin_phone
return false if admin.phones.count < 2
end
end
But much to my surprise this doesn’t work anymore. There’s not much information on this ActiveRecord new feature on the web, but according to the current documentation the behavior has changed:
“If the before_validation callback throws :abort, the process will be aborted and ActiveRecord::Base#save will return false. If ActiveRecord::Base#save! is called it will raise an ActiveRecord::RecordInvalid exception. Nothing will be appended to the errors object.”
So, the working implementation became as follows:
class Phone < ApplicationRecord
belongs_to :admin
before_destroy :dont_destroy_the_last_admin_phone
private
def dont_destroy_the_last_admin_phone
throw(:abort) if admin.phones.count < 2
end
end
This is the first time in 10 years using Ruby that I see real usage for a throw
statement, so my face is still a bit frowned.
But the reason behind this change is very sound: there are situations where a callback may return false but the intended behavior is not to halt the events chain. In the past if you experienced that issue you needed to explicitly return true
in the callback method, which was a little quirky.
Upgrading old applications
So what happens if you update your old rails 4 app to rails 5? That should not be a problem, since this new behavior is active by default only on freshly new created apps, see config/initializers/new_framework_defaults.rb
file:
# Do not halt callback chains when a callback returns false. Previous versions had true.
ActiveSupport.halt_callback_chains_on_return_false = false
Final implementation
By the way, my final implementation was completely different. I was not much satisfied with my callback code, because I felt the behavior was not very visible within the before_destroy
callback, and, as I wrote before, that throw :abort
still makes me frown 😉
The requirement could be stated as follows, which drives a totally different and clearer implementation, in my opinion:
“Admins must always have at least one phone”
So my final code was:
class Admin < ApplicationRecord
has_many :phones
validates :phones, presence: true
end
class Phone < ApplicationRecord
belongs_to :admin
end
Much better, isn’t it?
There is one slight difference with the previous implementation here: now you can actually destroy a Phone record even if it’s the last one for the given admin, for example via rails console. The enforcement is only at the Admin model level. This is not real a problem in the application, since in the web interface you always access phones in the context of the Admin.
TL;DR
My giveaways with this article are two:
- be aware of the recent changes with ActiveRecord callbacks
- the requirements wording may be as strong as to drive your actual implementation, at least for me
Andrea, when using the last implementation, you will not be able to create a new Admin without creating a phone at the same time, otherwise you will get an error: {:phones=>[“can’t be blank”]}
Serguei, thank you for the comment.
You are right, that error doesn’t happen with the first implementation that uses
throw
, so there’s a slightly different behavior.I think the last implementation is more strict, if you can’t destroy the last phone number, then it makes sense that new records must also have that phone number.
`