Ruby's ability to "overlay" default implementations of constants, methods and variables via the prepend
or prepend_features
method on Module
can be a helpful tool when dealing with gems overriding setter methods for attributes.
Scenario 🧫
On a Ruby on Rails project, we were recently migrating from the attr_encrypted
to the lockbox
gem for encrypting database fields. For live applications which cannot accept hours of downtime, the migration path is a multi-step process which includes a period where both gems are used side by side until all data has been migrated from the old columns to the new columns.
Both gems integrate into Rails models via their own macro-style methods one is supposed to add to the models' class definitions:
class User < ApplicationRecord
attr_encrypted :email, key: key
attr_encrypted :phone, key: key
encrypts :email, :phone, migrating: true
end
Under the hood, both gems then dynamically generate the respective getter and setter methods for the attributes (email
, email=
, phone
and phone=
in this example).
In addition to that, we were also overriding the setter methods email=
and phone=
ourselves to do some normalization on the provided values before assigning them. Combining this with the generated setters from attr_encrypted
introduces a lot of fuzz: in what order are the implementations called - if at all - and what does super
mean in which context? In order to eliminate all this confusion from the start, we previously decided to make our own implementation the "source of truth" and instead of relying on super
calls, just integrate the respective parts of attr_encrypted
's implementation into our own:
def phone=(value)
normalized_number = Normalizers::PhoneNumber.normalize(value)
self.encrypted_phone = encrypt(:phone, normalized_number)
instance_variable_set(:@phone, normalized_number)
end
Problem 💥
Now, during the migration phase were we utilize both gems in parallel, our own implementation would of course also need to integrate the internals of both gems in our own overriden implementation. Plus we would need to ensure the attr_encrypted
related code is removed again once the migration phase is over 🤯.
This seemed overwhelming and just way too many things to take care of for our own tiny model implementation. Integrating gems should ideally not interfere too much with our own plans to normalize attributes before assigning them. In addition to that, integrating so deeply with a gem, that understanding the code required reading the gems internals beyond the "normal" instructions in the README also comes with a high price on maintainability.
So we needed to find another way to use both gems in parallel while also guaranteeing our values are normalized before assigning and before encrypting them.
Rescue 🚑
A post on the arkency blog describes how Ruby's prepend
method on Module
can be utilized to override or better "overlay" methods added directly onto the class by a gem. One can prepend an anonymous module inline with the own implementation to either fully override the gem's implementation or just "prepend" one's own implementation and then call super()
to still invoke the code generated by the gem.
We simply want to "prepend" our own normalization step before the gems start to do their magic and ideally don't want to get into the details of what they are actually doing. So utilizing super()
after the normalization step fits our use case perfectly. In the model class definition, this could look like this:
class User < ApplicationRecord
attr_encrypted :email, key: key
attr_encrypted :phone, key: key
encrypts :email, :phone, migrating: true
prepend(Module.new do
def phone=(value)
normalized_number = Normalizers::PhoneNumber.normalize(value)
super(normalized_number)
end
end)
end
This "overlays" or "prepends" our normalization step before the class' implementation of the setter method, even when it's changed by any of the included gems. So we make sure the value is normalized before it is assigned and the gems' implementation of the setter invoked afterwards without any need for us to fiddle with internal details.
⚠️ Note however, that for the sake of readability and consistency, our model class definition follows Rubocop's Rails Style Guide:
Group macro-style methods (
has_many
,validates
, etc) in the beginning of the class definition.
Following this class layout, our own implementation is sure to be processed after the gems' methods were defined and we are prepending our normalization step before the final definition of the setter method. Ordering the definitions differently in our class would impact the value of super()
here.
Cleanup 🧽
While prepending an anonymous module directly inside the class definitions works perfectly fine for our use case here, it still looks very verbose and suspiciously distracting for anyone reading over the model definition. And as we made use of this technique in multiple models within the project, we extracted the boilerplate into a model concern. The goal was to hide the details of the prepending trick while at the same time making the normalization step more visible and explicit to the reader.
We extracted a more general Normalizable
concern. We already had multiple normalizer classes in the project. They all follow the same pattern and expose a single normalize
class method as their public API. So it just made sense tie the implementation of the Normalizable
model concern close to those normalizers:
module Normalizable
extend ActiveSupport::Concern
class_methods do
def normalize(attr, with:)
prepend(Module.new do
define_method("#{attr}=") do |value|
normalized_value = with.public_send(:normalize, value)
super(normalized_value)
end
end)
end
end
end
Utilizing this, we can change the previous example to:
class User < ApplicationRecord
include Normalizable
attr_encrypted :email, key: key
attr_encrypted :phone, key: key
encrypts :email, :phone, migrating: true
normalize :phone, with: Normalizers::PhoneNumber
end
This hides the internal details of our own implementation of the setter while still making it explicit that we are doing a normalization on the attribute. Similar to the original implementation inlining prepend
with an anonymous module, this approach of course still only works as intended if the normalize
macro in the class definition is defined after any other setter methods generated by gems are defined. However, in our case it seemed most fitting to place the `normalize calls at the end of the macros section anyways.
Top comments (0)