GemPatch: monkeypatch rubygems responsibly
If you must extend or change a gem’s functionality, it’s a good idea to make sure that your change won’t break with the next update. There isn’t really an automated way to make sure if that, so instead I prefer to crash the app until someone reviews the patch code.
Wouldn’t tests catch this?
I find that relying on private methods can have low-level insidious consequences that are hard to cover it tests. This is why I prefer this extra precaution. Besides, if your idea got upstreamed into the gem, you probably want to remove the patch.
Usage
- Copy the below code into your
lib/gem_patch.rb
(or wherever is appropriate in your case). - In Rails: create a file
config/initializers/_gem_patch.rb
andrequire 'gem_patch'
there. - Add the following line before or after the monkeypatch:
GemPatch.require 'gem-name-you-are-patching', '1.3.0', details: <<-TEXT
Explain what to look out for when reviewing this patch.
TEXT
Now you’ve indicated that the code in this file depends on this particular version of the gem. You can also use version constraints like ~> 1.3.0
, but you probably don’t want to risk relaxing the constraint if you’re patching something that you shouldn’t.
Any time Ruby encounters the above line, and the loaded gem version is not matching, it will raise error with a helpful explanation that looks like this:
GemPatch::CompatibilityError: The patch specified in /path/to/your/file:1 expected gem
gem-name
to have version 1.2.3, but it is now 1.2.4. If the patch is obsolete, remove it. Otherwise, update it as necessary, and bump the required gem version.
The code for lib/gem_patch.rb
# GemPatch helps you monkeypatch gems responsibly.
#
# If you monkeypatch a gem, or depend on its private code, `GemPatch.require`
# will crash your app when the gem updates. To fix it, you are instructed to
# review the code and either update the patch, remove it, or confirm that it's
# still compatible by updating the required version.
#
# EXAMPLE:
#
# GemPatch.require 'gem-name', '~> 2.3.1', details: <<-TEXT
# This feature should be added sometime in the next versions of gem-name.
# 1. If the feature has been added, remove the patch.
# 2. Otherwise, review to make sure that code is still compatible.
# TEXT
#
# class GemName
# def some_method
# # Monkeypatch code
# end
# end
#
module GemPatch
module_function
CompatibilityError = Class.new(RuntimeError)
def require(name, *expected, details:, actual: Gem.loaded_specs[name].version)
constraint = Gem::Dependency.new(name, *expected)
unless constraint.match?(name, actual)
raise CompatibilityError, "The patch specified in " \
"#{caller(1..1).first} expected gem `#{name}` to have version " \
"#{expected.join(", ")}, but it is now #{actual}." + <<~TEXT
If the patch is obsolete, remove it. Otherwise, update it as necessary,
and bump the required gem version.
DETAILS
#{indent(details.strip_heredoc, 2)}
TEXT
end
end
def indent(text, amount)
' ' * amount + text.split("\n").join("\n" + ' ' * amount)
end
end
Tests for test/lib/gem_patch_test.rb
require 'test_helper'
class GemPatchTest < ActiveSupport::TestCase
test '.require raises error when version does not match constraint' do
exception = assert_raises(GemPatch::CompatibilityError) {
GemPatch.require 'gem', '~> 0.2.0', actual: '0.1.0', details: <<-TEXT
Some additional instructions:
1. Point one
2. Point two
TEXT
}
assert_includes exception.message, 'gem_patch_test.rb'
assert_includes exception.message, '0.2.0'
assert_includes exception.message, '0.1.0'
assert_includes exception.message, 'additional instructions'
end
test '.require does not raise error when version matches the constraint' do
assert_nothing_raised {
GemPatch.require 'gem', '~> 0.2.0', actual: '0.2.2', details: <<-TEXT
Some additional instructions:
1. Point one
2. Point two
TEXT
}
end
end
Code snippets in this post are covered by 0BSD License.