Problem
You want to have a way to skip all callbacks for all models in Rails.
Before we begin
Two things:
Skip callbacks if this is a debugging session
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. For more Ruby learning resources, visit rubyandrails.info. You can also find me on Ruby.social or Linkedin or Twitter
Top comments (0)