Safeguarding from Deprecation Regressions During an Upgrade

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 opens a new window ).

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 setting ActiveSupport::Deprecation.behavior with the function obtained from the ActiveSupport::Deprecation::DEFAULT_BEHAVIORS constant.
  • config.active_support.disallowed_deprecation supports the same options as the deprecation 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 by disallowed_deprecation should be used or if it should fallback to the one defined in deprecation.
  • config.active_support.report_deprecations this configuration can be set to false to set both behaviors configurations listed above as :silence. Note that any value other than false 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 opens a new window 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.

  1. The deprecation cannot be re-introduced.

  2. 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 opens a new window .

Get the book