Rails Dead Columns

It happens. This column has to go. But in a Rails app, there are a few problems. You can’t just drop it.

My first expectation is that we would create a migration and do something like this.

1
2
3
4
5
6
class RemoveMiddleNameFromUsers < ::ActiveRecord::Migration
  def change
    remove_column :users, :middle_name
    remove_column :users, :gender
  end
end

This will generally be fine but there are a few practical issues.

  • Is any of my code still using this column?
  • What happens to the production code in the time between the migration and new code using it?

Still in use?

The obvious thing to do is search the code for use of this column name, but sometimes that can be tricky. The name would be fairly common leading to difficult searching. You could also be accessing it in a fairly inconsistent way like via send and string interpolation or something.

The best way that we’ve found out that we are still using something is to freak out in test/development/staging if it is accessed. If we didn’t have the test coverage, we’d probably log it’s usage in production and check the logs for use.

Deploy

Even if you aren’t using the column, there’s still an issue during the deploy window after the migration and before the server reboots. Rails has cached all the columns from right after it booted up so that it can know what to write to the table when you save a new record (among other things).

This means that it still assumes that column is there and when it reads/writes it will look for or set that column. Obviously this will not work. So what we need is two deploys wherein we first tell ActiveRecord that column no longer exists. Then sometime in the future, we can actually remove it.

Incidentally, I’ve noticed a read-only (def readonly? returns true) ActiveRecord model always does a SELECT * instead of looking for specific columns and doesn’t have this issue.

Code

Here is the code use for these purposes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
module Mixins
  module DeadColumns
    extend ActiveSupport::Concern

    class DeadColumnError < StandardError; end

    # to run in config/initializers to remove things globally
    class All
      class << self
        def init
          return if @active_record_init
          @active_record_init = true
          ActiveRecord::Base.send(:include, Mixins::DeadColumns)
        end

        def death_watch(name, *columns)
          init
          @registered_tables ||= {}
          @registered_tables[name.to_s] = columns.collect(&:to_s)
        end

        def table_columns_registered(name)
          @registered_tables[name.to_s] || []
        end
      end
    end

    included do
      class << self
        alias_method_chain :columns, :death
      end
    end

    module ClassMethods

      def dead_column_list
        return @dead_columns if @dead_columns
        @dead_columns = Mixins::DeadColumns::All.table_columns_registered(self.table_name).dup
      end

      def columns_with_death
        @columns_with_death ||= columns_without_death.reject{|c| dead_column_list.include?(c.name.to_s) }
      end

      def dead_columns(*args)
        to_kill = args.collect(&:to_s)
        dead_column_list.concat(to_kill)

        to_kill.each do |col|

          define_method("#{col}") do
            raise DeadColumnError, "#{self.class.name}##{col}"
          end

          define_method("#{col}?") do
            raise DeadColumnError, "#{self.class.name}##{col}?"
          end

          define_method("#{col}_changed?") do
            raise DeadColumnError, "#{self.class.name}##{col}?"
          end
        end

        # reset all these ActiveRecord caches
        @dynamic_methods_hash = @columns_hash = @column_names = @content_columns = @column_defaults = @columns = @columns_with_death = nil
      end
    end

    def attribute_names
      super - self.class.dead_column_list
    end

    def as_json(options = nil)
      options ||= {}
      options[:only] = self.class.column_names if options[:only].nil?
      super(options)
    end
  end
end

Then in an initializer, we do this:

1
2
3
4
# config/dead_columns.rb

Mixins::DeadColumns::All.death_watch(:users, :middle_name, :gender)
Mixins::DeadColumns::All.death_watch(:tasks, :old_price_info)

We used to mix this in to each model, so it was more like this:

1
2
3
4
5
class User < ActiveRecord::Base
  include Mixins::DeadColumns

  dead_columns :middle_name, ::gender
end

This worked with a slightly modified version of the above code. We stopped using that version, though, when we switched to heavily using engines, and now have many User (and other) models. The initializer works better to make sure it hits all of them.

Conclusion

Hopefully, this is helpful as an example of some code to deploy so you can safely remove your columns. It feels good to drop those columns and now you can do it without the pain.

Copyright © 2017 Brian Leonard