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
# Do not halt callback chains when a callback returns false. Previous versions had true. ActiveSupport.halt_callback_chains_on_return_false = false
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.
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