How to skip all callbacks for all models in Rails

How to skip all callbacks for all models in Rails

Different approaches for avoiding all callbacks on a Rails Active Record model

ยท

7 min read

Problem

You want to have a way to skip all callbacks for all models in Rails.

Before we begin

Two things:

  1. Skip callbacks if this is a debugging session

  2. Don't try to skip all callbacks in your business logic or production code. This is a strong hint for refactoring.

Context

Let's assume you have the following model:

class User < ApplicationRecord
  before_save :downcase_name
  after_save :add_title
  before_validation :ensure_username

  private

  def downcase_name
    self.name = name.downcase
  end

  def add_title
    self.title = "The #{name}"
  end

  def ensure_username
    self.username = self.email.split("@").first if username.blank?
  end
end

I used one single type of callback on save. But the solutions we will explore will try to skip all callbacks.

If you plan to use this I think you should first make sure that all callbacks work:

require "test_helper"

class UserTest < ActiveSupport::TestCase
  def test_callback_changes_name_and_title
    user = User.new(name: "TEST", email: "username@domain.com")

    user.save

    assert_equal "test", user.name
    assert_equal "The test", user.title
    assert_equal "username", user.username
  end
end

Solution 1: Using an Suppressor

I think this is the idiomatic way to achieve this in Rails if you need to suppress create and destroy callbacks -> use ActiveRecord::Suppressor (thank you Rob for sharing this)

ActiveRecord::Suppressor prevents the receiver from being saved during a given block

Here is how to use it:

require "test_helper"

class UserTest < ActiveSupport::TestCase
  def test_suppressor_skip_callbacks
    user = User.new(name: "TEST", email: "username@domain.com")

    # ๐Ÿ‘‡ An example of using User.supress
    User.suppress do
      user.save
    end

    assert_equal "TEST", user.name
    refute user.title
    refute user.username
  end
end

Looking at the tests for it from Rails codebase it seems that Suppressor only works for save and create callbacks.

Here is a test that shows that it does not work on create do

# app/models/user.rb
class User < ApplicationRecord
  before_destroy :before_destroy_action

  private

  def before_destroy_action
    raise "Can't destroy this record"
  end
end

# test/models/user_test.rb

require "test_helper"
class UserTest < ActiveSupport::TestCase
  def test_suppressor_does_not_work_on_destroy
    user = User.create(name: "TEST", email: "username@domain.com")

    User.suppress do
      # It confirms that it will raise RuntimeError 
      assert_raise RuntimeError do
        user.destroy
      end
    end
  end
end

So I will explore below two more methods to stop all callbacks. But again if you are only trying to stop the creation of other records from callbacks try to use this.

Solution 2: Use conditional callbacks

You can use the following feature from Rails:

Here the solution will be to have an attribute that is by default nil or false and then define all callbacks with unless :attribute so that when we set that attribute to true, it will skip that callback:

class User < ApplicationRecord
  attr_accessor :skip_callbacks

  before_save :downcase_name, unless: :skip_callbacks?
  after_save :add_title, unless: :skip_callbacks?
  before_validation :ensure_username, unless: :skip_callbacks?

  private

  def skip_callbacks?
    skip_callbacks
  end

  def downcase_name
    self.name = name.downcase
  end

  def add_title
    self.title = "The #{name}"
  end

  def ensure_username
    self.username = self.email.split("@").first if username.blank?
  end
end

Here is how you can use it for one model:

require "test_helper"

class UserTest < ActiveSupport::TestCase
  def test_with_setting_skip_callbacks
    user = User.new(name: "TEST")
    user.skip_callbacks = true
    user.save

    assert_equal "TEST", user.name
    refute user.title
    refute user.username
  end
end

This solution is simple to add it to one model or to start using it if you start a greenfield project. But if you already have 1000 models, it will be painful to change callbacks on all models to change them to conditional callbacks. Extra pain points if they already have a condition defined.

Advantage of this solution: It uses only public APIs/methods from Active Record so it should be safe to use and Rails upgrades will not break it.

Solution 3: Patching ApplicationRecord to allow removing all callbacks

For this solution, we will use reset_callback and set_callback and patch ApplicationRecord to allow execution of something like without_callbacks with a block.

Let's first define this to work with one or multiple callbacks:

# app/models/application_record.rb

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  def self.without_callbacks(*callbacks)
    saved_callbacks = save_current_callbacks(callbacks)
    remove_callbacks(callbacks)
    yield
    restore_callbacks(saved_callbacks)
  end

  private

  def self.save_current_callbacks(callbacks)
    callbacks.map { |callback| [callback, self.__callbacks[callback]] }.to_h
  end

  def self.remove_callbacks(callbacks)
    callbacks.each { |callback| reset_callbacks(callback) }
  end

  def self.restore_callbacks(saved_callbacks)
    saved_callbacks.each { |callback, chain| set_callbacks(callback, chain) }
  end
end

and you can use it like this for one callback or many:

require "test_helper"

class UserTest < ActiveSupport::TestCase
  def test_without_callbacks_does_not_change_name_or_title
    user = User.new(name: "TEST")

    User.without_callbacks(:save) do
      user.save
    end

    assert_equal "TEST", user.name
    refute user.title
  end

  def test_without_callbacks_does_not_change_name_title_or_username
    user = User.new(name: "TEST", email: "username@domain.com")

    User.without_callbacks(:save, :validation) do
      user.save
    end

    refute user.title
    refute user.username
    assert_equal "TEST", user.name
  end
end

And then if you want you can add one more thing to the ApplicationRecord:

# app/models/application_record.rb

  def self.without_all_callbacks
    callbacks = self.__callbacks.keys
    without_callbacks(*callbacks) { yield }
  end

and then you can use this like this:

require "test_helper"

class UserTest < ActiveSupport::TestCase
  def test_without_callbacks_does_not_change_name_title_or_username
    user = User.new(name: "TEST", email: "username@domain.com")

    User.without_all_callbacks do
      user.save
    end

    refute user.title
    refute user.username
    assert_equal "TEST", user.name
  end
end

Warning

I would advise against using this solution because it uses private methods from ActiveRecord. These methods are open to be changed/removed and should be treated that way.

From all the methods I used in the code, only reset_callbacks is a public Ruby on Rails API. See it on api.rubyonrails.org. The others are methods not listed publicly on the api.rubyonrails.org. So they might change without any deprecation or warning.

Improving on solution 3: Put the custom code in .irbrc

An intermediate solution would be not to pollute your application code with patching Active Record and put the patching into .irbrc file that you can put in the root of your Ruby on Rails app:

# .irbrc

class ApplicationRecord < ActiveRecord::Base
  def self.without_all_callbacks
    callbacks = self.__callbacks.keys
    without_callbacks(*callbacks) { yield }
  end

  def self.without_callbacks(*callbacks)
    saved_callbacks = save_current_callbacks(callbacks)
    remove_callbacks(callbacks)
    yield
    restore_callbacks(saved_callbacks)
  end

  private

  def self.save_current_callbacks(callbacks)
    callbacks.map { |callback| [callback, self.__callbacks[callback]] }.to_h
  end

  def self.remove_callbacks(callbacks)
    callbacks.each { |callback| reset_callbacks(callback) }
  end

  def self.restore_callbacks(saved_callbacks)
    saved_callbacks.each { |callback, chain| set_callbacks(callback, chain) }
  end
end

So now when you start the Rails server the ApplicationRecord is not affected. So if you wrote those tests they will fail.

But if you open rails c then the without_all_callbacks and without_callbacks methods will be available on all your models.

Checking before upgrade

If you decide to go for adding this kind of code, then I suggest you add the following test:

require 'test_helper'

class ActiveRecordCallbacksTest < ActiveSupport::TestCase
  def setup
    @model = User.new
  end

  test "should have access to reset_callbacks" do
    assert(
      User.respond_to?(:reset_callbacks, true),
      "User models should have access to `reset_callbacks` to implement without_callbacks method"
    )
  end

  test "should have access to __callbacks hash" do
    assert_respond_to(
      @model,
      :__callbacks,
      "User model should have access to `__callbacks` hash to implement without_callbacks method"
    )
  end

  test "__callbacks should be a hash with keys as symbols to implement without_callbacks method" do
    callbacks = @model.__callbacks.keys
    assert_kind_of(
      Symbol,
      callbacks.first,
      "Expected `ApplicationRecords#__callbacks` should be a hash with keys as symbols"
    )
  end
end

And run this check when you upgrade your Rails version.

Special case: Rails 7.1 normalizes method

โš ๏ธ None of the techniques I presented above will skip normalizes transformations.

If you have something like this in your models:

class User < ApplicationRecord
  normalizes :email, with: -> email { email.strip.downcase }
  normalizes :name, with: -> name { name.strip.downcase }
end

Then skipping callbacks will not stop normalization from happening. Thus if you save the User record it will update the email and name as specified in the transformation

Conclusion

In conclusion, skipping all callbacks for all models in Rails can be achieved using various methods such as ActiveRecord::Suppressor, conditional callbacks, or patching ApplicationRecord.

However, it is important to consider the potential drawbacks of each method and use them cautiously, especially in production code. Always test your solutions and be mindful of Rails version upgrades that may affect your implementation


Enjoyed this article?

๐Ÿ‘‰ Join my Short Ruby News newsletter for weekly Ruby updates from the community and visit rubyandrails.info, a directory with learning content about Ruby.

๐Ÿ‘ Subscribe to my Ruby and Ruby on rails courses over email at learn.shortruby.com - effortless learning anytime, anywhere

๐Ÿค Let's connect on Ruby.social or Linkedin or Twitter where I post mainly about Ruby and Rails.

๐ŸŽฅ Follow me on my YouTube channel for short videos about Ruby

Did you find this article valuable?

Support Lucian Ghinda by becoming a sponsor. Any amount is appreciated!