22 December 2020
Getting creative with constantize in Ruby on Rails
In a recent project, our static code analyzer, Rubocop, complained that a case statement we had written made our code too complex. In this article, I am going to show you how we refactored that code to use constantize. Constantize is a String method that “tries to find a declared constant with the name specified in the string”. It is not seen widely in Ruby on Rails as it has a reputation as being insecure. It does not help that the documentation is vague in how you can use this method in a safe and useful way. We will use it to convert strings to module names that we can then call a method on. I think our usage is safe, easy to understand, expandable, and easy to test. Even Rubocop is happy with the complexity of the solution.
We were working on an order processing system. The problem we were trying to solve was that sometimes the orders would contain line items that were seen as one item to customers but were two, or more, items in the warehouse. We needed a way to recognize those items and do a swap of the order’s line item before passing the order on to the warehouse for fulfillment. The following code example is a simplified version of what we came up with initially. All line items for each order are passed to the translate method. Either the line item is replaced if the SKU matches one in the case statement, or the original line item is passed back.
class LineItemMapping
def translate
case line_item.sku
when 'Product-sku-1'
return
{
SkuNumber => 'replacement-sku-1',
Quantity => (line_item.quantity * 2).to_s,
UOM => 'EA'
}
when 'Product-sku-2'
return
{
SkuNumber => 'replacement-sku-2',
Quantity => line_item.quantity.to_s,
UOM => 'EA'
},
{
SkuNumber => 'replacement-sku-3',
Quantity => line_item.quantity.to_s,
UOM => 'EA'
}
else
return
{
SkuNumber => line_item.sku,
Quantity => line_item.quantity.to_s,
UOM => ‘EA’
}
end
end
end
Sadly, Rubocop deemed this code as too complex, and since the actual code handled about a half-dozen products which are not shown above, it was right.
We tried several approaches to simplify this. Most solutions involved instantiating one or more classes with either instance or class methods that we might invoke via the SKU number. None were ideal and they just made things harder to understand.
Since our code was running in a Sidekiq job and did not interact with anything the user entered, we felt that we could safely try using constantize. We would use this String method to reference a set of modules. These modules would handle the translation of items to those that the warehouse knew. This approach worked well and we will take a look at that now.
To start, we created a constant named ADJUSTMENTS, which mapped item SKUs that needed to be changed to the new modules that would return the changed item(s).
ADJUSTMENTS = {
'Product-sku-1’ => 'SplitProduct1',
'Product-sku-2' => 'SplitProduct2',
'Product-sku-3' => 'SplitProduct3',
'Product-sku-4' => 'SplitProduct4',
'Product-sku-5' => 'SplitProduct5'
}.freeze
This gave us the extra benefit of not needing a case statement and so, simplifying our code complexity. For each item, we now check if the SKU is in the hash.
def adjustable?
ADJUSTMENTS.key?(line_item.sku)
end
Our main processing method is now simplified to the following code.
def translate
return adjustments if adjustable?
Line_item_attributes
end
Normal items are returned in the correct format via the line_items_attributes method referenced above. Here is that code.
def line_item_attributes
{
'SkuNumber' => line_item.sku,
'Quantity' => line_item.quantity.to_s,
'UOM' => 'EA'
}
end
If the SKU is found to be adjustable, we call the adjustments method shown below. Here is where constantize works its magic. The string containing the module name is resolved and converted to a constant that Rails knows as a module. This works the same as if we had hardcoded the module names inside a case statement.
def adjustments
"LineItemMappings::Adjustments::#{
ADJUSTMENTS[line_item.sku]
}".constantize.adjust(
line_item: line_item
)
end
One of these modules is shown below. It has one method, the adjust method called in the code above. We pass in our line item data and get the new line item back.
module Adjustments
module SplitProduct1
def self.adjust(line_item:)
{ 'SkuNumber' => 'replacement-sku-1',
'Quantity' => (line_item.quantity * 2).to_s,
'UOM' => 'EA'
}
end
end
end
This approach of using modules yields consistent results that are easy to test. For these tests, we can do the following simple RSpec test.
RSpec.describe LineItemMappings::Adjustments::SplitProduct1,
type: :service do
let(:line_item) { FactoryBot.create(:line_item) }
describe '#adjust' do
it 'translates Product-sku-1 into 2 replacement-sku-1' do
expect(
described_class.adjust(line_item: line_item)
).to eq(
{
'SkuNumber' => 'replacement-sku-1',
'Quantity' => (line_item.quantity * 2).to_s,
'UOM' => 'EA'
}
)
end
end
end
Summary
By using constantize we were able to come up with a creative solution to reduce the code complexity in our order processing application. Breaking out the line item adjustments into separate modules gave us a solution that is easy to follow, expandable, and easy to test. I hope you will consider using constantize when conditions in your next project look right for it.