ActiveRecord Callback Chain: A Cool (and Weird) Edge Case

Image by jcomp

Well, it’s been a while. After a lot of time… I’m back! ( ̄▽ ̄)/

Keannu Reeves John Wick GIF - KeannuReeves JohnWick Back GIFs

If you haven’t read my last post, give it a look here: “!” and “?”: Understanding One of Ruby’s Coolest Naming Conventions.

Today, we’ll be talking about a bet a friend of mine here at Codeminer42 and I [almost] made and I [almost] won. Keep up with me.

A Calm Thursday

There I was, dealing with a piece of legacy code in a Ruby on Rails project I was working on at the time. At a given point, I’ve found some really "creative" piece of code (if you know what I mean (ಠ‿↼)) and I had to show it to someone. (If you are a developer, you might know what I’m talking about. You have to share the "experience").

So, right above the section I was showing him, there were some AR (Active Record, for short) validations and that’s where it starts to get interesting. The validation code was something like this:

# We used Reform to create Contracts and validate incoming parameters.
# These validations work pretty much like if you were inside an Active Record model.
class MyNiceOperation
  class Contract < Reform::Form
    property :name

    validate :name_should_be_capitalized

    def name_should_be_capitalized
      return if ('A'..'Z').include? name&.first
      errors.add(:name, 'Should be Capitalized')
  end

Seems very normal, right?

My friend, however, has noticed the way I was adding errors to my model. It went like this:

- Hey, you know that if you add the errors like that it won’t stop your model to be saved, right?
- What you mean, man? That’s the way we make validations fail in AR. ( ̄▿ ̄;)!?
- Are you sure? It will be saved unless you trow an :abort flag right after the validation fails
- Well, I was ’til 5 seconds ago… ಠ_ಠ
(Then the interesting part)
- Then let’s bet… this month’s salary! (‾▿‾)
(See!? the audacity!! (¬、¬))
- WHAT? Dude, you don’t want to do that… ಠ▃ಠ
- Come on! Let’s bet…

I don’t know if you have ever played poker. But when someone does an All-In it can only mean three things: he is either bluffing or has a really good hand, or he thinks he has a really good hand. And you can’t bluff with technology.

Even I, having quite a few good years as a professional developer with Rails, backed up for a moment ಠಠ. For the sake of our financial health, I turned down the bet – however, the challenge was still in my hands to prove I was right (or that he was wrong ᕦ(òóˇ)ᕤ).

While he was searching for an example to give me, I resorted to a test application I always keep around to make any kind of PoCs I might need and quickly ran these commands in the console:

$ rails g model Person name
$ rails db:migrate

In a few seconds, I had my model ready for my PoC. So I navigated to the just-created model and typed pretty much the same validation example above inside it:

# == Schema Information
#
# Table name: people
#
#  id         :bigint           not null, primary key
#  name       :string
#  ...

class Person < ApplicationRecord
  validate :name_should_be_capitalized

  def name_should_be_capitalized
    return if ('A'..'Z').include? name&.first
    errors.add(:name, 'Should be Capitalized')
  end
end

And in rails console, then:

michael = Person.new(name: 'michael')
# => #<Person id: nil, name: "michael", ...>

michael.valid? 
# => false
# Okay, the validation failed ಠ_ಠ

michael.errors 
# => #<ActiveModel::Errors:0x... @messages={:name=>["Should be Capitalized"]}, ...>
# We've got errors ヾ(-_- )ゞ

michael.save   
# => false
# Well, well HAHAHA ( ̄ ³ ̄)

You should’ve seen his face. I was like: "lol, you’re lucky we didn’t bet".

But here lies the question: why did my friend got confused about this feature in the first place?

A misconception

After staring and testing my code for the next 15 minutes, he went to search for the piece of code that has tricked him. After a few more minutes, he showed me something like the following snippet, which was also inside an old codebase:

# person.rb
class Person < ApplicationRecord
  # notice that the validation is now being performed inside a before save callback
  before_save do
    errors.add(:name, 'Should be Capitalized') unless ('A'..'Z').include? name&.first
    throw :abort
  end
end

person = Person.new name: 'jack'

person.save
# => false
# => Ok, that's correct

person.errors
# => #<ActiveModel::Errors:0x00005578507fc790 @base=#<Person id: 2, name: "jack", created_at: "2021-02-12 16:19:34", updated_at: "2021-02-12 16:19:34">, @messages={:name=>["Should be Capitalized"]}, @details={:name=>[{:error=>"Should be Capitalized"}]}>
# => And the errors are NOT empty...

So, here is where the confusion started. This validation code was pre existent inside a before_save callback. Then, a Rails gem upgrade happened. Before the version in question, adding an error to the object or returning a falsey value would halt the callback chain and return the added errors. But that stopped working after the Rails upgrade and he needed to halt the chain by throwing an :abort flag.

Let’s test things out here by writing very similar code, this time without the throw statement:

# person.rb
class Person < ApplicationRecord
  before_save do
    errors.add(:name, 'Should be Capitalized') unless ('A'..'Z').include? name&.first
    # bye bye throw :abort
  end
end

person = Person.new name: 'jack'

person.save
# => true
# => oops, that's not what you might be expecting 

person.errors
# => #<ActiveModel::Errors:0x00005578507fc790 @base=#<Person id: 2, name: "jack", created_at: "2021-02-12 16:19:34", updated_at: "2021-02-12 16:19:34">, @messages={}]}>
# => And no errors to be seen... (╯°□°)╯︵ ┻━┻

That shows us two things: first, as of Rails 5, AR will not stop if you add errors inside callbacks like before_save. Second, It will not just not stop but it will erase the errors of the model in question as well (which actually makes a lot of sense).

However, if we write a slightly modified version of this code, we can actually get it to work without needing to throw anything:

# person.rb
class Person < ApplicationRecord
  # now I have an around_validation callback instead of a before_save
  around_validation do
    errors.add(:name, 'Should be Capitalized') unless ('A'..'Z').include? name&.first
  end
end

person = Person.new name: 'jack'

person.save
# => false
# => Nice!

person.errors
# => #<ActiveModel::Errors:0x00005578507fc790 @base=#<Person id: 2, name: "jack", created_at: "2021-02-12 16:19:34", updated_at: "2021-02-12 16:19:34">, @messages={:name=>["Should be Capitalized"]}, @details={:name=>[{:error=>"Should be Capitalized"}]}>
# => And here are the errors (☞⌐■_■)☞

So, rails WILL stop the execution if we add errors to the model AFTER the validation cycle starts and BEFORE it ends (what, again, makes a lot of sense).

Time for a Reformation

So, lessons learned: first of all, it’s always good to tone down our confidence in how we handle edge cases so we never bet on them, lol. (Let’s put the table back (╮°-°)╮┳━┳)

Second, I’d say adding errors to models inside callbacks is a very bad idea. Callbacks weren’t supposed to be used for that purpose, especially because it can lead to tricky and hard-to-identify edge cases like this one.

If I can go any further with this advice I would say to avoid validations altogether inside AR models.

Instead of adopting a "per model" validation style, I’d highly recommend a "per use-case" style instead.
Let’s give it a try on an example using Reform (that I have used in the first code snippet).

Reform works using either ActiveModel or dry-validation as the validation engine. Its official docs recommend using dry-validation, but for simplicity’s sake, we’ll be using ActiveModel. It also has a series of very useful features that gives out a lot of flexibility.

The example below is a very simple one, using the reform-rails gem, but you can certainly have much bigger and more complex validations.

# person.rb
class Person < ApplicationRecord
  # nothing here |_・)
end

# person_form.rb
class PersonForm < Reform::Form
  include Reform::Form::ActiveRecord
  property :name

  validates :name, presence: true
  validate :name_should_be_capitalized

  def name_should_be_capitalized
    return true if ('A'..'Z').include? name&.first
    errors.add(:name, 'Should be Capitalized')
  end
end

form = PersonForm.new(Person.new)
#=> <PersonForm:0x000055d2498f3b78
#  ...
#  @fields={"name"=>nil},
#  @model=#<Person:0x000055d2498f3bf0>,
#  ...
#  @result=#<Reform::Contract::Result:0x000055d2498f2ae8 @failure=nil, @results=[]>>

form.validate(name: "jack")
#=> false # NICE! (๑>◡<๑)

form.errors
#=> #<Reform::Form::ActiveModel::Validations::Result::ResultErrors:0x000055d249d1b750
#  ...
#  @result=#<Reform::Contract::Result:0x000055d249d1b840 @failure={:name=>["Should be Capitalized"]}, @results=[{:name=>["Should be Capitalized"]}]>,
#  ...>

form.errors.messages
#=> {:name=>["Should be Capitalized"]}

Two nice things occur here. First, Reform serializes and maps the given fields in the validate step to the model’s attributes in a way that’s completely agnostic, as long as the model object implements the same public API.

If you unplug Rails, it should continue to work with no issue (if using ActiveModel, you’d still have to keep the dependency to it, but with dry-validation you can get rid of the entire framework). Second, it validates the input against the schema defined inside the form, not inside the model.

That way, you can have multiple validations, with completely different rules for different purposes without having to add tons of complexity to the model, making the input data validation a responsibility of the use case, not of the model.

There are, of course, many other ways of doing this. That’s MY preferred way of doing validations within my projects. But if you have other fun ways of validating input data, let us know in the comments!

Well, it’s a shame I didn’t take that bet ┐( ̄ヮ ̄)┌


Reference:

We want to work with you. Check out our "What We Do" section!