Ruby Mixin Exploration


Recently I’ve been playing with Ruby’s class_eval and define_method to append methods to an object. Meta programming is something new for me and I had a problem that would benefit from using some of Ruby’s cool features.

The Problem

There was a model that had 6 fields that needed to be generated at some point in time. Some fields had to be unique and others had to be generated a bit differently. Here is a contrived example of what I was doing:

class User < ActiveRecord::Base
  def generate_api_token
    begin
      self.api_token = SecureRandom.urlsafe_base64(32)
    end while self.class.exists?(api_token: self.api_token)
    self.api_token
  end

  def generate_new_password
    self.password = SecureRandom.urlsafe_base64(16)
  end
end

class Widget < ActiveRecord::Base
  def generate_slug
    begin
      self.slug = SecureRandom.urlsafe_base64(32)
    end while self.class.exists?(slug: self.slug)
    self.slug
  end
end

If these generators are spread across multiple models, it will cloud the source code and a test will need to be written for every generator. If we utilized shared examples, there wouldn’t be a need to write a test for every single generator.

The Goal

In the end, I wanted an easy to read DSL. Adding more DSL to a class can mean a “code smell”, however in this case, I do not think it is a code smell.

# app/models/some_model.rb
class SomeModel < ActiveRecord::Base
  include Generatable

  generatable :some_field,   lambda { SecureRandom.hex(32) }
  generatable :unique_field, lambda { SecureRandom.hex(32) }, unique: true
end

s = SomeModel.new
s.generate_some_field #=> some 32 character string
s.generate_unique_field #=> some 32 character unique string

The pros

  • Easy to read
  • Easy to figure out where generatable is being loaded from
  • Compact
  • Testing becomes easier (more on this later)

The cons

  • Can’t use the model in the lambda
  • Adds to model complexity
  • Lose some control on testing (more on this later)

Solution

This is a perfect case where ActiveSupport::Concern applies. This is functionality that multiple models can utilize and thus, should be put into a mixin and included to those models. My initial implementation only made a simple generator but, it does show how class_eval works and is an easy example to grok.

# app/models/concerns/generatable.rb
module Generatable
  extend ActiveSupport::Concern

  module ClassMethods
    # @param [String] field the name of the field you want to generate
    # @param [Proc] generator the lambda or Proc that you wish to use as the
    #   generator
    # @param [Hash] options
    # @option options :unique
    # @return [void]
    def generatable field, generator, options={}
      field = field.to_s
      class_eval do
        define_method "generate_#{field}" do
          self.send("#{field}=", generator.call)
        end
      end
    end
  end

end

The next requirement I had to fulfill was generating unique values. This took a little bit more thought.

# app/models/concerns/generatable.rb
module Generatable
  extend ActiveSupport::Concern

  module ClassMethods
    # @param [String] field the name of the field you want to generate
    # @param [Proc] generator the lambda or Proc that you wish to use as the
    #   generator
    # @param [Hash] options
    # @option options :unique
    # @return [void]
    def generatable field, generator, options={}
      field = field.to_s

      if options[:unique]
        class_eval do
          define_method "generate_#{field}" do
            begin
              self.send("#{field}=", generator.call)
            end while self.class.exists?(field.to_sym => self.send(field))
            self.send(field)
          end
        end
      else
        class_eval do
          define_method "generate_#{field}" do
            self.send("#{field}=", generator.call)
          end
        end
      end
    end
  end

end

This isn’t exactly pretty. Infact, one could argue that this is a bit difficult to follow due to the if options[:unique] statement. Another possible solution could be to break this up into three methods like so:

# app/models/concerns/generatable.rb
module Generatable
  extend ActiveSupport::Concern

  module ClassMethods
    # Appends a generator
    # @param [String] field the name of the field you want to generate
    # @param [Proc] generator the lambda or Proc that you wish to use as the
    #   generator
    # @param [Hash] options
    # @option options :unique
    # @return [void]
    def generatable field, generator, options={}
      if options[:unique]
        generatable_unique field, generator
      else
        generatable_simple field, generator
      end
    end

    # Append a unique generator
    # @param [String] field the name of the field you want to generate
    # @param [Proc] generator the lambda or Proc that you wish to use as the
    #   generator
    def generatable_unique field, generator
      field = field.to_s
      class_eval do
        define_method "generate_#{field}" do
          begin
            self.send("#{field}=", generator.call)
          end while self.class.exists?(field.to_sym => self.send(field))
          self.send(field)
        end
      end
    end

    # Append a simple generator
    # @param [String] field the name of the field you want to generate
    # @param [Proc] generator the lambda or Proc that you wish to use as the
    #   generator
    def generatable_simple field, generator
      field = field.to_s
      class_eval do
        define_method "generate_#{field}" do
          self.send("#{field}=", generator.call)
        end
      end
    end
  end

end

Testing

Earlier I mentioned that this makes testig easier and then followed up with a loss of control in the tests. The Generatable module will need to be tested in some fashion, whether it is directly testing it via including it on a dummy model or making a shared example where the test would have it_behaves_like somewhere.

Testing a module directly:

# Module Testing
module Say
  extend ActiveSupport::Concern
  def hello
    "hello"
  end
end

class DummyClass
  include Say
end

describe Say do
  let(:dummy_class) { DummyClass }
  let(:dummy) { dummy_class.new }

  it "get hello string" do
    dummy_class.hello.should == "hello"
  end
end
# spec/support/shared_examples/generatable_fields.rb
shared_examples 'generatable fields' do |field|
  describe "#generate_#{field}" do
    subject { described_class.new }

    it "should change #{field}" do
      expect {
        subject.send("generate_#{field}")
      }.to change(subject, field.to_sym)
    end
  end
end
# spec/models/user_spec.rb
describe User do
  include_examples 'generatable fields', :api_token
  include_examples 'generatable fields', :password
end

Resources