I have been thoroughly enjoying working with Ruby this past year (thanks Lee!). However, only recently have I been getting brave/comfortable/wise enough to try out some metaprogramming. Okay, so maybe I am a little slow… The scourge of making deadlines and releasing software meant I sometimes just had to give up trying to get to an elegant solution that I thought was possible, but was unable to make work in the time allotted. I try to be pragmatic, if nothing else.
But here is a little example of how easy it is to exploit Ruby’s omniscient metaprogramming system.
Background
The project is a Rails app using MongoMapper. I needed to enhance the way we create User Accounts to accommodate importing a user from a CSV file (dumped from the hospital’s account management system).
First up: “Insert New Record” — which went pretty smoothly. Next, I wanted to permit merging import data with an existing account. (For example, changing the last name when married status changes.)
Round 1: Get it to work
Using a TDD approach and RSpec to flesh out the low-level class behavior, I “snuck up on the answer,” one small test at a time. I tend to use a “get it to work with brute force” approach at the outset. Leading to sometimes bulky code as can be seen below. So, one-by-one, I kept adding to the merge code for each new attribute that would allow updating.
NOTE: I added some (all?) of the code comments below for this blog post. Plus I took liberties to not show everything…
class Account include MongoMapper::Document # Rest of class omitted def self.create_or_merge(fields) raise ArgumentError if fields.nil? # Some code omitted # There are 2-3 legal ways to identify a unique account :-( account = Account.find_by_identifiers(login, msid, doctor_num) if account.nil? # Create if login.blank? login = generate_login(fields[:first_name], fields[:last_name], msid, doctor_num) fields[:login] = login end account = Account.create(fields) account.save! else # Merge first_name = fields[:first_name] last_name = fields[:last_name] phone = fields[:phone] email = fields[:email] doctor_num = fields[:doctor_num] account.last_name = last_name unless last_name.blank? account.first_name = first_name unless first_name.blank? account.phone = phone unless phone.blank? account.email = email unless email.blank? account.save! end account end end
Round 2: Extract Method
The first step to making “create_or_merge” simpler was to yank out the blob of merge code into it’s own method. So the logic in create_or_merge looks a bit cleaner:
- If can’t find account,
- create new one;
- else
- merge this data into existing account.
class Account include MongoMapper::Document # Rest of class omitted def self.create_or_merge(fields) raise ArgumentError if fields.nil? # Some code omitted # There are 2-3 legal ways to identify a unique account :-( account = Account.find_by_identifiers(login, msid, doctor_num) if account.nil? #Create if login.blank? login = generate_login(fields[:first_name], fields[:last_name], msid, doctor_num) fields[:login] = login end account = Account.create(fields) account.save! else #merge account.merge(fields) end account end def merge(fields) puts "Merging #{fields.inspect}" first_name = fields[:first_name] last_name = fields[:last_name] phone = fields[:phone] email = fields[:email] doctor_num = fields[:doctor_num] self.last_name = last_name unless last_name.blank? self.first_name = first_name unless first_name.blank? self.phone = phone unless phone.blank? self.email = email unless email.blank? self.doctor_num = doctor_num unless doctor_num.blank? save! end end
Round 3: Introduce dynamic method calls
It is plain to see the repeating nature…based on the fields being passed in:
self.KEY = VALUE unless VALUE.blank?
If only there were a way to not have to write out repeating lines of code for each attribute we need to merge. Well, enter the ability to invoke instance methods by name, and passing in parameters:
Normal:
self.last_name = last_name unless last_name.blank?
Metaprogramming:
self.send("last_name", "Franklin")
And getting the name from the fields hash
self.send("#{k.to_s}=", v) unless v.blank?
Also, there is a need to tailor which fields are allowed to be merged, or overwritten; hence, the introduction of this bit of “allow_overwrite” complexity.
def merge(merge_fields) allow_overwrite = [:first_name, :last_name, :doctor_num, :phone, :email] # Only merge fields that are permitted... tossing out any illegal fields fields = merge_fields.each {|k,v| merge_fields.delete(k) unless allow_overwrite.include?(k.to_sym)} fields.each_pair do |k,v| # Update the field with new data, if available self.send("#{k.to_s}=", v) unless v.blank? end save! end
Round 4: Compress slightly, removing one iteration loop
Instead of looping twice through the fields, I reduced it to a single pass.
def merge(merge_fields) allow_overwrite = [:first_name, :last_name, :doctor_num, :phone, :email] merge_fields.each do |k,v| next unless allow_overwrite.include?(k.to_sym) self.send("#{k.to_s}=", v) unless v.blank? end save! end
The Tests
Here is a snippet from my RSpec tests:
# Uses metaprogramming too... def check_merging(field, new_value) @fields[field.to_sym] = new_value expect { account = Account.create_or_merge(@fields) }.to_not change { Account.count }.by(1) act = Account.find(@account.id) act.instance_eval(field).should == new_value end describe "being merged" do before do @group_num = "009015" group_name = "Country Doctor Pediatrics" grp = Group.find_by_group_num_or_create(@group_num, group_name) @doctor_num = "6709#{rand(20)}" @fields = {:login => "jmadison", :email => "johns@CountryPedDocs.com", :doctor_num => @doctor_num, :name => 'Dr. John Madison', :first_name => "John", :last_name => "Madison", :group_name => group_name, :group_num => @group_num } @account = nil expect { @account = Account.create_or_merge(@fields) }.to change{ Account.count }.by(1) end it "should merge new last name" do new_value = "Mattson" field = "last_name" check_merging(field, new_value) end it "should merge new first name" do new_value = "Mary Lou" field = "first_name" check_merging(field, new_value) end it "should merge new phone" do new_value = "123-321-1234" field = "phone" check_merging(field, new_value) end it "should merge new email" do new_value = "some_good_email@humptyfratz.biz" field = "email" check_merging(field, new_value) end it "should merge new doctor_num" do new_value = @doctor_num.reverse field = "doctor_num" check_merging(field, new_value) end after do if @account @account.destroy @account.save end Account.hard_delete end end