Build your own Domain-Specific Language with Ruby
“So, what is your favourite design pattern?” As odd as it sounds, it is a common question between fellow engineers and in job interviews.
But aren’t we supposed to weigh our options according to our application logic and pick a suitable one? Leaving that aside, my answer would probably be internal DSLs.
Arguably, one of the most famous books written on this subject is Design Patterns: Elements of Reusable Object-Oriented Software by the ****"Gang of Four". However, you won’t find DSLs in this book, and which is why exactly it is interesting. Most of the original design patterns described in this book were developed without Ruby in mind. By leveraging Ruby’s features, DSL provides a way to implement our own little language.
When writing Ruby code, we use internal DSLs quite often. Rake, RSpec, ActiveRecord and Sinatra come with their own internal DSL. Let’s examine two common testing libraries among Rubyists: minitest and RSpec.
# minitest sample
class PlaneTest < MiniTest::Test
.
.
.
def test_engines
assert_equal(4, Plane.engines.length)
end
end
# RSpec sample
describe PlaneTest do
.
.
.
it "should have 4 engines" do
expect(Plane.engines.length).to eq(4)
end
end
The former minitest example uses pure Ruby*,* our PlaneTest
class inherits from MiniTest::Test
and we create instance methods for each test. On the RSpec example, we use the two methods describe
and it
for the same purposes. Under the hood, RSpec uses a DSL to allow this syntax that looks like plain English.
Simplistic DSL syntax is enabled by a couple of features in Ruby:
- Omitting parentheses on method invocations:
load("http://example.com/pictures", "my/dir/local")
load "http://example.com/pictures", "my/dir/local"
eval
method evaluates the argument and runs the content as Ruby program text.
eval("1 + 1") #=> 2
instance_eval
will go one step further and change the context(self
) to the object thatinstance_eval
is being called on.
class Universe
attr_reader :secret
def initialize
@secret = 42
end
end
uni = Universe.new
uni.instance_eval { secret } #=> 42
Now that we have covered the basic principles, let’s implement our own little JSON parser that iterates over a collection of objects that have similar structure and extracts a Ruby array with the selected properties. It can be useful when dealing with large JSON files. The below example is from Yahoo’s woeid locations collection:
[
{
"name": "Birmingham",
"placeType": {
"code": 7,
"name": "Town"
},
"url": "http:\/\/where.yahooapis.com\/v1\/place\/12723",
"parentid": 23424975,
"country": "United Kingdom",
"woeid": 12723,
"countryCode": "GB"
},
{
"name": "Bournemouth",
"placeType": {
"code": 7,
"name": "Town"
},
"url": "http:\/\/where.yahooapis.com\/v1\/place\/13383",
"parentid": 23424975,
"country": "United Kingdom",
"woeid": 13383,
"countryCode": "GB"
},
.
.
and more
.
.
]
Our JSONParser
class with on_path
and extract
methods to setup the parser:
class JSONParser
attr_reader :data
def initialize(&block)
@content_arr = []
@data = []
block.call(self) if block_given?
end
def on_path(file_path)
file = File.read(file_path)
@content_arr = JSON.parse(file)
end
def extract(source_key, target_key=source_key)
unless @data.length > 0
@content_arr.length.times { |_| @data << {} }
end
@content_arr.each_with_index { |json_obj, i| @data[i][target_key] = json_obj[source_key] }
end
end
We can pass in a block with the relevant method calls while creating a new JSONParser
:
parser = JSONParser.new do |parser|
parser.on_path('gb_woeid.json')
parser.extract("name")
parser.extract("woeid")
parser.extract("country")
parser.extract("countryCode", "country_code")
end
# Test the data
puts parser.data
# Prints
{"name"=>"Birmingham", "woeid"=>12723, "country"=>"United Kingdom", "country_code"=>"GB"}
{"name"=>"Blackpool", "woeid"=>12903, "country"=>"United Kingdom", "country_code"=>"GB"}
.
.
.
We can use instance_eval
to get rid of the need of calling methods on our parser object by changing our initialize
method:
def initialize(&block)
@content_arr = []
@data = []
# block.call(self) if block_given?
# instance eval changes the value of self as it executes the block
instance_eval(&block) if block_given?
end
With our new initialize
and by omitting parentheses our parser is much more DSL-like now:
parser = JSONParser.new do
on_path "gb_woeid.json"
extract "name"
extract "woeid"
extract "country"
extract "countryCode", "country_code"
end
DSLs are great when dealing with an isolated problem repeatedly. Now that we’ve developed one of our own, we know what is going on in the background when we use one.
For more on Ruby and DSLs: