Skip to main content

Prepending Modules to the Rescue

Utilizing Ruby's Module prepend functionality to safely integrate with gems overriding setter methods.

ยท 6 min read
Max Mulatz

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.

Max Mulatz

Max Mulatz

Whitespace wrestler on a functional fieldtrip

We're hiring

Work with our great team, apply for one of the open positions at bitcrowd