Skip to content

Ruby DSL 101

Posted on:March 31, 2017

One of the most powerful concept of Ruby is metaprogramming. This make the language uniq, powerful and expressive allowing you to create DSL which is tailored to your need. I would try to guide you guys to create a basic DSL using ruby metaprogramming.

Step 1: Ruby blocks

Ruby are simple. You can write a method that can yield. Something like,

def report
  puts "Header"
  yield
  puts "Footer"
end

report do
  puts "From block"
end

Output

Header
From block
Footer

Step 2: Ruby blocks sending object

We can send objects from block method back to block caller.

def report
  puts "Header"
  yield 'From block'
  puts "Footer"
end

report do |var_from_block|
  puts var_from_block
end

Here is what is happening,

Output

Header
From block
Footer

Step 3: instance_eval

class Report
  def initialize(&block)
    puts "Header"
    instance_eval &block if block_given?
    puts "Footer"
  end
end

Report.new do
  puts "From block"
end

Here the puts in line #10 is run on the context of report object. The following example will make things clearer.

class Report
  def initialize(&block)
    puts "Header"
    instance_eval &block if block_given?
    puts "Footer"
  end

  def my_print(str)
    puts str
  end
end

Report.new do
  my_print "From block"
end

Here you see there is no my_print method defined globally but at line #14 the block has access to my_print method. It is because the block is evaluated with the context of a report object.

Step 4: First draft of DSL

class Report
  def initialize(data, &block)
    @data = data
    @columns = []
    instance_eval &block if block_given?
  end

  def column(column_name)
    @columns << column_name
  end

  def print
    @data.each do |row|
      @columns.each do |column|
        puts row[column]
      end
    end
  end
end

data = [
  {name: 'Jitu', status: 'Married'},
  {name: 'Razeen', status: 'Single'}
]
report = Report.new(data) do
  column :name
end

report.print()

Output

Jitu
Razeen

Step 5: Second draft of DSL

Lets add some more features to make more out of the DSL.

class Column
  attr_accessor :key, :title, :footer
  def initialize(key, options={})
    @key = key
    @title = options[:title] || key.capitalize
    @footer = options[:footer]
  end
end

class Report
  def initialize(data, &block)
    @data = data
    @columns = []
    instance_eval &block if block_given?
  end

  def column(key, options={})
    @columns << Column.new(key, options)
  end

  def print
    puts @columns.map { |column| column.title }.join(', ')
    puts '=' * 10
    @data.each do |row|
      puts @columns.map { |column| row[column.key] }.join(', ')
    end
    print_footer
  end

  def print_footer
    if @columns.any? { |column| column.footer }
      puts '=' * 10
      footers = @columns.map do |column|
        if column.footer
          values = @data.map do |row|
            row[column.key]
          end
          values.inject{ |sum, el| sum + el }.to_f / values.size
        else
          ' ' * 5
        end
      end
      puts footers.join(' ')
    end
  end
end
data = [
  {name: 'Jitu', status: 'Married', age: 33},
  {name: 'Razeen', status: 'Single', age: 2}
]
report = Report.new(data) do
  column :name, title: 'Nick name'
  column :age, footer: :avg
end

report.print()

Output

Nick name, Age
==============
Jitu,   33
Razeen,  2
==============
      17.5

Now you can go ahead and implement new features on this abstraction. For the source code used here you can have look here. I once build a gem using the same concept query_report. Here is a demo.