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


Nice post, thanks! Very important stuff every Ruby dev should know. dsl ftw :)
March 04, 2009 at 7:25 PM