The problem
In my current job, we faced calculation errors when operating with float
for Money.
After some investigation, we found this article
Our first approach was to find every usage of money attributes and parse them with BigDecimal
.
This solution has some drawbacks. First, we would need to replace it in many places. Second, it doesn't prevent future developers to use float.
In order to overcome those issues, I wanted to enforce a validation over every money attribute.
Then if a future execution accidentally does money_attr = 233.0
(float) we could detect that error and report it.
After thinking for a moment I thought that would be preferable to do a conversion (float->BigDecimal
) rather than raising an error.
So I'd like to write Ruby code to say: "hey if someone tries to assign a float
to a money attribute then convert it to BigDecimal
"
The Solution
In order to do that I came up with this solution:
module BigDecimalCheck
def self.included(klass)
klass.extend(ClassMethods)
end
module ClassMethods
def enforce_big_decimal(*attrs)
attrs.each do |attr|
define_method :"#{attr}=" do |value|
# try to convert argument to BigDecimal
instance_variable_set(:"@#{attr}", BigDecimal(value.to_s))
end
end
end
end
end
class Rate
attr_accessor :money
include BigDecimalCheck
enforce_big_decimal :money
end
With that code in place a consumer code would work like this
r = Rate.new
r.money = 33 # works
r.money = 33.0293 # works
r.money = "33" # works
r.money = "33.0293" # works
r.money = "no numeric" # Argument Error
How this solution work?
self.included
it is a hook that Ruby modules provide. It is called when the module is included and receives the class that included it.klass.extend(ClassMethod)
Let's say that klass = Foo, then this would be the same as doing:
class Foo
extend ClassMethod
# Now I'm able to call methods in ClassMethod form here
end
which will inject methods from ClassMethod into Foo object at class scope.
-
enforce_big_decimal
def enforce_big_decimal(*attrs)
attrs.each do |attr|
define_method :"#{attr}=" do |value|
# try to convert argument to BigDecimal
instance_variable_set(:"@#{attr}", BigDecimal(value.to_s))
end
end
end
If I call enforce_big_decimal :unit_price, total_price
It will define two methods:
def unit_price=(value)
parsed_value = BigDecimal(value.to_s) # raise error if cannot parse
instance_variable_set(:@unit_price, parsed_value)
end
def total_price=(value)
parsed_value = BigDecimal(value.to_s) # raise error if cannot parse
instance_variable_set(:@total_price, parsed_value)
end
Conslusion
I've shown an example of how to generalize the solution of a problem by using ruby meta-programming techniques.
I hope it can help you solve similar problems.
Feel free to ask questions or suggest improvements.
Thanks for reading!
Top comments (0)