Safeguarding from Deprecation Regressions During an Upgrade
You are upgrading a Rails application. You finished fixing a deprecation warning and it’s not present anymore. You continue working on other tasks and one day you find out the deprecation is back in the codebase. New code was added using the deprecated behavior, but it was not detected and now it needs to be fixed again…
How can you prevent that from happening and, at the same time, let the team know?
Disallowed Deprecations
In version 6.1, Rails began including a configuration to disallow deprecations (check the PR ).
This allows us to configure specific deprecations that we want to handle in a different way compared to the setting we pick for deprecations in general.
There are multiple similar configurations and it can be confusing. Here’s a quick summary:
config.active_support.deprecation
supports these options::raise
,:stderr
,:log
,:notify
, or:silence
, and it’s used to specify how we want to handle deprecation warnings in general. When we set one of the values (default is:stderr
), Rails will configure a behavior by settingActiveSupport::Deprecation.behavior
with the function obtained from theActiveSupport::Deprecation::DEFAULT_BEHAVIORS
constant.config.active_support.disallowed_deprecation
supports the same options as thedeprecation
config, and it’s used to specify what to do with some subset of deprecations that we can configure with the next setting. The default behavior for disallowed deprecations is to raise them (the:raise
option).config.active_support.disallowed_deprecation_warnings
this configuration is paired with the previous one, and it is an array consisting of string, symbols, and regular expressions, that is used to compare to deprecation warning messages to decide if the behavior defined bydisallowed_deprecation
should be used or if it should fallback to the one defined indeprecation
.config.active_support.report_deprecations
this configuration can be set tofalse
to set both behaviors configurations listed above as:silence
. Note that any value other thanfalse
behaves like not setting this configuration at all (the default), which means it will leave the previous settings unchanged.
Apart from those configuration values, we can also set ActiveSupport::Deprecation.behavior
and ActiveSupport::Deprecation.disallowed_behavior
directly with an anonymous function.
Let’s see how we would use this feature with an example. Let’s say we just finished fixing this deprecation in the codebase.
In that same PR fixing the deprecation we would include this code in the development.rb
and test.rb
files:
# We add a regexp or a substring that is unique enough to only match this deprecation and not others.
config.active_support.disallowed_deprecation_warnings = [
/Merging .* no longer maintains both conditions/
]
After this is merged, if this deprecation is re-introduced, it will raise an exception during development and when running tests.
Backporting
This is a nice feature in Rails 6.1, but we understand: your application is not there yet, and you need to solve this problem too.
The solution is to use a feature of ActiveSupport::Deprecation
that is present in all Rails versions. We can define custom functions as behaviors, we are not limited to the ones defined in ActiveSupport::Deprecation::DEFAULT_BEHAVIORS
.
Instead of setting the behavior by using a symbol for config.active_support.deprecation
(disallowed_deprecation
doesn’t exist), we can set ActiveSupport::Deprecation.behavior
directly with a lambda function that will be executed for each call to warn
.
This is an example of this function for Rails 4.2:
ActiveSupport::Deprecation.behavior = ->(message, callstack) {
# This would be equivalent to setting config.active_support.disallowed_deprecation_warnings.
# Split substrings from regexps since it's faster to compare strings first.
disallowed_str = []
disallowed_regex = []
if disallowed_str.any? { |subs| message.include?(subs) } || disallowed_regex.any? { |reg| reg === message }
# This would be equivalent to setting config.active_support.disallowed_deprecation.
ActiveSupport::Deprecation::DEFAULT_BEHAVIORS[:raise].call(message, callstack)
end
# This would be equivalent to setting config.active_support.deprecation.
# Use the original value instead of :stderr when needed.
ActiveSupport::Deprecation::DEFAULT_BEHAVIORS[:stderr].call(message, callstack)
}
And now we can do something similar: after fixing a deprecation warning in Rails 4.2, we can change this code adding a substring or a regular expression to compare with the deprecation message.
ActiveSupport::Deprecation.behavior = ->(message, callstack) {
disallowed_str = ["You are passing an instance of ActiveRecord::Base to `find`"]
disallowed_regex = []
if disallowed_str.any? { |subs| message.include?(subs) } || disallowed_regex.any? { |reg| reg === message }
ActiveSupport::Deprecation::DEFAULT_BEHAVIORS[:raise].call(message, callstack)
end
ActiveSupport::Deprecation::DEFAULT_BEHAVIORS[:stderr].call(message, callstack)
}
For performance reasons, after the upgrade is completed, review the list of messages added in the custom behavior and remove the ones that are also removed from the Rails codebase. That way we avoid extra string comparisons that will never match.
Note that some deprecations are present in more than 1 Rails version, those could be re-introduced later if they are just removed from the list without care.
Different Rails Versions
The signature of the behavior function and the available behaviors changed over the years, so the previous code snippet must be updated to account for that. Here’s a list of the snippet for different version ranges:
Rails 5.2 and above
For these versions, the function needs 4 arguments instead of 2:
ActiveSupport::Deprecation.behavior = ->(message, callstack, deprecation_horizon, gem_name) {
disallowed_str = []
disallowed_regex = []
if disallowed_str.any? { |subs| message.include?(subs) } || disallowed_regex.any? { |reg| reg === message }
ActiveSupport::Deprecation::DEFAULT_BEHAVIORS[:raise].call(message, callstack, deprecation_horizon, gem_name)
end
ActiveSupport::Deprecation::DEFAULT_BEHAVIORS[:stderr].call(message, callstack, deprecation_horizon, gem_name)
}
Rails 4.0 to 5.1
The original snippet works for all these versions:
ActiveSupport::Deprecation.behavior = ->(message, callstack) {
disallowed_str = []
disallowed_regex = []
if disallowed_str.any? { |subs| message.include?(subs) } || disallowed_regex.any? { |reg| reg === message }
ActiveSupport::Deprecation::DEFAULT_BEHAVIORS[:raise].call(message, callstack)
end
ActiveSupport::Deprecation::DEFAULT_BEHAVIORS[:stderr].call(message, callstack)
}
Rails 3.0 to 3.2
The raise
behavior did not exist back then, so we need to raise an exception instead:
ActiveSupport::Deprecation.behavior = ->(message, callstack) {
disallowed_str = []
disallowed_regex = []
if disallowed_str.any? { |subs| message.include?(subs) } || disallowed_regex.any? { |reg| reg === message }
raise message
end
ActiveSupport::Deprecation::DEFAULT_BEHAVIORS[:stderr].call(message, callstack)
}
Before 3.0
For these older versions, there were only 2 behaviors available by default: :test
(equivalent to :stderr
) and :development
(equivalent to :log
).
ActiveSupport::Deprecation.behavior = ->(message, callstack) {
disallowed_str = []
disallowed_regex = []
if disallowed_str.any? { |subs| message.include?(subs) } || disallowed_regex.any? { |reg| reg === message }
raise message
end
ActiveSupport::Deprecation::DEFAULT_BEHAVIORS[:test].call(message, callstack)
}
Conclusion
With this approach we can solve 2 problems at the same time.
-
The deprecation cannot be re-introduced.
-
When somebody writes code that would re-introduce a deprecation, they can see the message that explains the code they should use instead.
Do you need help upgrading your application? Is your team too busy shipping new features? We can help .