Factory Girl and MongoMapper

You were probably hoping for some Rosey the Riveter poster…

Factory Folder
Factory Folder

Instead, I am going to extend my small MongoMapper example to include Factory Girl. The steps are pretty simple:

  1. Go here to install…
  2. Create your factories
  3. Use the factories in Cucumber/RSpec

Factory Construction

I created a new “factories” folder under the spec folder:

The factories for User and Event are quite simple:

Factory.define :user do |u|
  u.name ('a'..'z').to_a.shuffle[0..7].join.capitalize
end

and

require 'factory_girl'
def dummy_word(len=6)
  ('a'..'z').to_a.shuffle[0..len].join.capitalize
end

def dummy_date
  secs_in_day = 24*60*60
  Time.now + (rand(60)*secs_in_day - 30)
end

Factory.define :event do |e|
  e.title "#{dummy_word} #{dummy_word 3} #{dummy_word 10}"
  e.date  dummy_date
end

Refactor Original Setup

Instead of using this style of test data creation:

@event = Event.create(:title => "Code Retreat Timbuktoo", :user => @fred)

We will use the new factory as follows:

@event = Factory(:event, :title => "Code Retreat Timbuktoo", :user => @fred)

Refactor Cucumber

The given went from this:

Given /^A set of events$/ do
  fred = User.find_or_create_by_name("fred")
  (1..10).each do
    Event.create(:title=>"#{dummy_word} #{dummy_word 3} #{dummy_word 10}",
                 :date => dummy_date,
                 :user => fred)
  end
  harry = User.find_or_create_by_name("harry")
  (1..10).each do
    Event.create(:title=>"#{dummy_word} #{dummy_word 3} #{dummy_word 10}",
                 :date => dummy_date,
                 :user => harry)
  end
  Event.count.should == 20
end

to this – including refactoring out dummy_title, and reducing it to one loop:

Given /^A set of events$/ do
  fred = User.find_or_create_by_name("fred")
  harry = User.find_or_create_by_name("harry")
  (1..10).each do
    evt = Factory(:event, :title => dummy_title,
                          :date  => dummy_date,
                          :user  => fred)
    evt = Factory(:event, :title => dummy_title,
                          :date  => dummy_date,
                          :user  => harry)
  end
  Event.count.should == 20
end

Subtle Details

The beauty of having tests is that I could easily mess around with getting some of the Factory Girl configuration stuff in the right place. Try something, run the test, adjust as needed until all are back to green.

The file features/support/env.rb got some additions so that Cucumber could find the factories:

$LOAD_PATH << File.expand_path('../../../app/model' , __FILE__)
require 'user'
require 'event'
require 'spec/factories/events.rb'
require 'spec/factories/users.rb'
load 'config/mongo_db.rb'

All the tests still pass!

More Complicated Example

For a project I work on, my factories look like this, with auto-creation of random IDs:

def random_months(months)
  day_in_secs = (24*60*60)
  (1+rand(months))*30*day_in_secs
end

# ----------- GROUP -----------
Factory.sequence :group_num do |n|
  "99#{n}#{rand(n)}"
end

Factory.define :group do |g|
    g.group_num {Factory.next(:group_num)}
    g.name "Greatest Group"
end
# ----------- ACCOUNT -----------
Factory.sequence :doctor_num do |n|
  "999992#{n}#{rand(200+n)}"
end

Factory.sequence :login do |n|
  "AB#{rand(n*68)}bx#{rand(200+n)}"
end

Factory.sequence :msid do |n|
  "CQ987Z12#{n}#{rand(n)}"
end

Factory.define :account do |a|
  pw = 'password'
  a.msid { Factory.next(:msid) }
  a.doctor_num { Factory.next(:doctor_num) }
  a.first_name "James"
  a.last_name "Jones"
  a.role 'user'
  a.password pw
  a.password_confirmation pw
  a.email Setting.get("AutoEmail")
  a.login { Factory.next(:login) }
end

# ----------- PATIENT -----------
Factory.sequence :patient_num do |n|
  "#{n}#{rand(300+n)}"
end

Factory.define :patient do |pt|
#  pt.patient_num "10000009"
  pt.patient_num {Factory.next(:patient_num)}
  pt.emr_num "1853286"
  pt.first_name "John"
  pt.last_name "Johnson"
  pt.dob {(Time.now - random_months(36))}
  pt.count_public_encounters 1
  pt.count_public_events 2
end

 

Ruby Metaprogramming in the Small

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