back

How to write a clean Ruby DSL - Part 2: Learning from Machinist

DSLs are a handy way to write beautiful, obvious code. In part 1, I showed what a DSL is, and briefly looked at why DLSs are handy in Ruby.

In this part, I’ll go through Machinist, a fun, simple plugin for helping deal with objects in Rails tests. You should be familiar with intermediate ruby meta programming…

Machinist’s purpose

To avoid using fixtures, a rails shop named Thoughtbot came up with a plugin called Factory Girl.

I summarized some of the more recent debate about fixtures here.

As Thoughtbot folks explain in Rails Antipatterns, they made Factory Girl to avoid the typical use of fixtures where existing fixtures become “sacrosanct” as developers notice that when they change them tons of tests break, which results in developers making new fixtures for every test.

Pete Yandell, Machinist’s author, liked the idea behind Factory Girl, but writes:

I thought the philosophy wasn’t quite right, and I hated the syntax.

So, Machinist is a good idea on how to improve testing + nice syntax / philosophy.

good dsl == nice syntax + philosophy ?

Machinist’s interface

Here’s how I might use machinist with cucumber. Pretend the app has a model named Person.

You would normally use machinist with Faker and with Shams / deal with uniqueness, but I’m ignoring this to keep things clear.

1
2
3
4

# scenario in a feature file
Given a person named "Mischa"

1
2
3
4
5
6

# step matcher in a ruby steps file
Given /^a person named "(.*)"$/ do |name|
  @person = Person.make(:name => name)
end

1
2
3
4
5
6
7
8

# blueprints.rb, usually put in rails_root/spec, or wherever you want.
Person.blueprint do 
  name {"Example name"}
  birthday {Time.now}
  hair_color {"Brown"}
end

If I ran that feature, @person would be an instance of my Person model, with a birthday a few seconds ago, brown hair, and the name “Mischa”. Machinist automatically sets the attributes that you don’t specify in the arguments to Model#make.

How does this work?

How it works

The core code for Machinist is short, but I’ve cut out a lot of stuff that isn’t key to writing a DSL (stuff dealing with plans, shams, associations, some error checking stuff too) If you want to learn how to use Machinist, just read the readme or the code on github.

Anyway,

Extend an everyday class (debatable)

Machinist extends active record to give the methods #make and #blueprint.

When I call #blueprint on a model with a block, machinist takes the block I pass and sticks it in a hash of blocks (you can have more than one blueprint). When I just call #blueprint on a model without a block, it returns the main blueprint, unless I specify which one I want from the hash. For our purposes, we can ignore the hash and think of models as only having one blueprint.

When I call #make on a model, machinist runs a lathe, which deals with assigning the object attributes.

Here are the #blueprint and #make methods, after that we’ll see the Lathe class.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
module Machinist
  
  module ActiveRecord
    
    module Extensions

      def self.included(base)
        base.extend(ClassMethods)
      end
    
      module ClassMethods
        # this gives us:
        # Model.blueprint do
        # end 
        def blueprint(name = :master, &blueprint) #default the name to a sybmol, take the block
          @blueprints ||= {}  # initialize a hash of possible blueprints for the model
          @blueprints[name] = blueprint if block_given?  # store the block in the hash
          @blueprints[name] # return the block
        end
  

        # This gives us:
        # Model.make do 
        # end
        def make(*args, &block)
          lathe = Lathe.run(self.new, *args)  # pass a new instance of self (e.g. Post.new) to Lathe
          lathe.object.save!   #actually save the object, using a bang to fail early
          lathe.object.reload  #reload to get everything
          lathe.object(&block) # if there was a block
        end

      end
    end
  
  end
end


class ActiveRecord::Base
  include Machinist::ActiveRecord::Extensions #...
end



Add sexy behavior

Machinist then adds some sugar to allow it to take blocks with undefined methods called inside like so:

1
2
3
4
5
6
7
8


Post.blueprint do # passing a block, which gets evaluated inside the lathe
  title {"hi"} # inside the block, calling a method on a lathe that won't be defined (title)
  author {"mischa"} # passing a block to the method on lathe that won't be defined
end


So, the Lathe class will have to deal with undefined methods that are model attributes. These methods will be sent with blocks that provide a way to generate the attribute on the fly.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75

require 'machinist/active_record' # we just looked at this

module Machinist

  # A Lathe is used to execute the blueprint and construct an object.
  # The blueprint is instance_eval'd against the Lathe.
  class Lathe
    def self.run(object, *args) #so, object here is a model instance (E.g. @post = Post.new)
      blueprint       = object.class.blueprint # get the main blueprint for the class (which is a block)
      attributes = args.pop || {} # get the attributes from the args. E.g. :name => "Titus"

     # now, returning a new lathe evaluate the block that we passed in with Model.blueprint
      returning self.new(object, attributes) do |lathe| #the .new method sends us to initialize
        lathe.instance_eval(&blueprint) #So, if I call name, then name will get send to method missing
      end
    end
    

   # so, initialize is called in in Lathe.run, after we have the attributes
   # the purpose of initialize is to set the values for the attributes that we specify in Make
   # so, if I call Post.make(:title => "Mischa's post"), then the name of the post will get set here.
    def initialize(object, attributes = {})
      @object = object #assign ivars
      @assigned_attributes = {}
      attributes.each do |key, value|  #for each of the attributes
        @object.send("#{key}=", value)  # set the value
        @assigned_attributes[key.to_sym] = value 
      end
    end
    
    # this returns the instance of the model
    def object
      yield @object if block_given?
      @object
    end
    
    attr_reader :assigned_attributes
    
    # method missing is going to get called when we say lathe.instance_eval(&blueprint)
   # the purpose of method missing, here, is to dynamically set the attributes
   # that we don't specify in Post.make
    
   # So, if we had a blueprint that was like:
   # Post.blueprint do 
   #   name {"hi"}
   #   date {Time.now}
   # end
   # and we called Post.name(:name => "foo")
   # both name and date would hit method missing for the lathe,
   # and then just get sent to the object (the post) itself.

    def method_missing(symbol, *args, &block)
      if @assigned_attributes.has_key?(symbol)  # if we've passed an attribute to make
        @object.send(symbol)  # just send it
      else # if we have to dynamically generate the attribute
         # send it with the generated attribute
        @object.send("#{symbol}=", generate_attribute(symbol, args, &block)) 
      end
    end
    
   # This is a simplified version of how machinist generates attributes
   # simplified enough to require no explanation, I think
    def generate_attribute(attribute, args)
      value = if block_given?
        yield
      else
        args.first
      end
      @assigned_attributes[attribute] = value
    end
    
  end
end

OK. So, that’s how machinist works. It extends ActiveRecord to give the #blueprint and #make methods, then inside those methods makes a calls a method on the lathe class, which makes a new lathe object which deals with autogenerating attributes that we didn’t specify in make.

February 25, 2009

  1. Soleone says:

    Nice post, thanks! Very important stuff every Ruby dev should know. dsl ftw :)