[Feature Proposal] Add method on ActiveRecord to safely deprecate attributes before dropping them

Problem Statement

When maintaining a production Rails application, we strive for zero downtime during migrations. To drop a model attribute, we must follow this workflow:

  1. Remove all references to the attribute from the application.
  2. Deploy.
  3. Add a migration that removes the database column.
  4. Deploy.
  5. Run the migration.

Step (1) can be a challenge. How can we be confident that we’ve removed references to the attribute? A source code search is a start, but that can miss cases, especially if the attribute name isn’t globally unique or there is meta-driven code. And the penalty for missing a reference is high: the application will raise an exception and crash at least for that request and perhaps entirely.

Proposal

It would be nice if there were a built-in way to deprecate an attribute for step (1), such that any reference to it would raise an exception or log a deprecation warning. It’s also important that the deprecated attributes would not be included in the model.columns or model.attributes so that your application process that started before your database migration executes doesn’t cause errors when trying to construct a new instance of your model (and attempt to set fields that no longer exist). This comes up if you have any default values for the field or other meta code that operates based on the cached schema.

Example Usage

In your model:

class User < ApplicationRecord
  # schema:
  #   id             :bigint
  #   name           :string, limit: 255
  #   favorite_color :string, limit: 255
  
  deprecates :favorite_color
end

Get an error (or log deprecation warning) if accessing that attribute directly:

user = User.create!(name: "Jane Doe")
user.name # => "Jane Doe"
user.favorite_color # => raises RuntimeError / logs deprecation

user = User.create!(name: "Jane Doe", favorite_color: "green") # => raises RuntimeError / logs deprecation

The schema still contains favorite_color, but the model omits it:

User.columns_hash["favorite_color"] => nil

Recommended workflow for removing database-backed attributes:

  1. Add the deprecates method to your model and update code to no longer read or write to the field (ensure tests pass with no exceptions)
  2. Deploy code, ensure no errors (or deprecation warnings for this field)
  3. Generate migration to drop the column and remove deprecates method
  4. Deploy with migration

The company I work at (Invoca, Inc) has been using a custom module that we include into ActiveRecord classes, and following the above workflow, and has been successfully doing zero-downtime migrations with our large Rails app. We would be happy to submit a PR if there is interest in adding this functionality.

1 Like

How is this feature different from ignored_columns that every model already supports? ActiveRecord::ModelSchema::ClassMethods

2 Likes

Here is a test script that I think show that all your requirements are already supported by that feature.

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  # Activate the gem you are reporting the issue against.
  gem "activerecord", "6.0.3"
  gem "sqlite3"
end

require "active_record"
require "minitest/autorun"
require "logger"

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :users, force: true do |t|
    t.string :name
    t.string :favorite_color
  end
end

class User < ActiveRecord::Base
  self.ignored_columns = [:favorite_color]
end


class BugTest < Minitest::Test
  def test_stuff
    user = User.create!(name: "Jane Doe")
    assert_equal "Jane Doe", user.name

    assert_raises do
      user.favorite_color
    end

    assert_nil User.columns_hash["favorite_color"]

    assert_raises do
      User.create!(name: "Jane Doe", favorite_color: "green")
    end
  end
end
1 Like

Hi @rafaelfranca, thank you very much for your thorough reply. You’re right, ignored_columns will meet all our requirements.

Our team is only just finishing an upgrade to Rails 5 (we’ve been on v4 for quite some time!) and we completely missed that this was already a feature added. That’s great to see and we will be able to remove our special case code.

Apologies for the noise here – and for not contributing this back a few years ago :slight_smile:

1 Like

No need to apologize. Your post made me realize this is not a well documented feature.

There is still opportunity to contribute here. What do you think to open a PR expanding the documentation of the ignored_collumns= method showing examples of usage like the one you provided in this post?

I knew about this feature because it was implemented by Shopify for the Rails 5.0 given we also had a module in our app with this behavior for a while.

2 Likes

Thanks Rafael. I submitted a PR with a proposal to expand the documentation. I would appreciate your feedback and assistance merging that in.

2 Likes