Safe monkeypatching

Ruby is the language of monkey patching. While it’s theoretically possible to avoid monkey patching, I’m 99% sure that your app contains at least a few of them (especially of you use Rails).

Monkey patching is usually considered as an anti-pattern, but sometimes it can’t be avoided.

The typical use case of monkey patching in a Rails app is a bug fix. If you’re using Rails 5 and the bug you’re hunting was only fixed in 5.1 which is not released yet, you’d have no other option than to copy-paste the fix and use a monkey patch.

But still, there are good and bad ways to add a monkey patch. This Pull Request introduces a configurable option for dumping a database. Before that patch, there was no way to configure dumping flags. Imagine that we’re using an older Rails and we still want to pass a custom flag. We’d have to monkeypatch that class:

# config/initializers/active_record_patches.rb
module ActiveRecordDbCommandPatch
  def run_cmd(cmd, args, action)
    # pass an extra flag to mysqldump
    if cmd == "mysqldump"
      args = args + ["—skip-add-drop-table"]
    end
    super(cmd, args, action)
  end
end
ActiveRecord::Tasks::MySQLDatabaseTasks.prepend(ActiveRecordDbCommandPatch)

Why is this way to monkey patch is not the best? Because when we upgrade to a new Rails version that has a configurable option, we may forget to clean up and this patch will still live in the app. Even worse, imagine that run_cmd method in Rails was refactored and the patch will introduce a bug.

We can improve it by 1) checking that run_cmd is available and 2) that configurable option is not available yet in the current Rails version.

# config/initializers/active_record_patches.rb
if ActiveRecord::Tasks::DatabaseTasks.respond_to?(:structure_dump_flags)
  raise "you're running the Rails version that no longer requires the patch"
end

module ActiveRecordDbCommandPatch
  def run_cmd(cmd, args, action)
    # pass an extra flag to mysqldump
    if cmd == "mysqldump"
      args = args + ["—skip-add-drop-table"]
    end
    super(cmd, args, action)
  end
end

# instance_method will raise with NameError is the method is not available
if ActiveRecord::Tasks::MySQLDatabaseTasks.instance_method(:run_cmd)
  ActiveRecord::Tasks::MySQLDatabaseTasks.prepend(ActiveRecordDbCommandPatch)
end

This way will help you to remove the patch as soon as you update Rails. There is also a way to use the Rails version as an indicator that the patch is no longer necessary:

if Rails::VERSION::MAJOR > 4
  raise "you're running the Rails version that no longer requires the patch"
end

For a large Rails app, it may be impossible to avoid monkeypatches. The best we can do is to inject them carefully, providing a safe way for a patch to be removed when it’s no longer necessary.

Happy monkeypaching!

Comments

comments powered by Disqus