Ruby Domain Specific Language :)

By reading this article you will learn what the DSL is and what it has in common with Ruby.

DSL, say welcome!

Referring to definition, DSL (Domain Specific Language) is a computer language specialized to a particular application domain. This means it is developed to satisfy specific needs. There are two types of DSL:

– An external DSL which requires its own syntax parser. A good known example may be the SQL language – it allows to interact with database in a language in which the database was not created.

– An internal DSL which itself does not have its own syntax but instead uses a syntax of a given programming language.

As you can probably guess we are going to stay focused on the second DSL type.

What does it do?

Basically, by making use of a Ruby metaprogramming, it allows to create your own mini-language. Metaprogramming is a programming technique that allows to write a code dynamically at runtime (on the fly). You may be unaware of this, but you probably use many different DSLs every day. To understand what a DSL can do, let’s take a look at a few examples below – all of these have one common element, but can you point it?

Rails routing

Rails.application.routes.draw do
  root to: 'home#index'

  resources :users do
    get :search, on: :collection
  end
end

Every person who have ever used Rails know a config/routes.rb file where we define application routes (mapping between HTTP verbs and URLs to controller actions). But have you ever wondered how does it work? In fact, it is just Ruby code.

Factory Bot

FactoryBot.define do
  factory :user do
    company
    sequence(:email) { |i| "user_#{i}@test.com" }
    sequence(:first_name) { |i| "User #{i}" }
    last_name 'Test'
    role 'manager'
  end
end

Writing tests often requires fabricating objects. Hence to avoid a waste of time, it would be a really good idea to simplify the process as much as possible. That is what the FactoryBot does – easy to remember keywords and a way of describing an object.

Sinatra

require 'sinatra/base'
 
class WebApplication < Sinatra::Base
  get '/' do
    'Hello world'
  end
end

Sinatra is a framework which allows you to create web applications from scratch. Could it be easier to define request method, path and response?

Other DSL examples might be Rake, RSpec or Active Record. The key element of each DSL is the use of blocks.

Building time

Time to understand what is hiding under the hood and how the implementation can look like.

Let’s assume we have an application which stores data about different products. We want to extend it by giving possibility to import data from a user defined file. Also, the file should allow to calculate values dynamically if needed. To achieve that, we decide to create DSL.

A simple product representation may have following attributes (product.rb):

class Product
  attr_accessor :name, :description, :price
end

Instead of using a real database we will just simulate its work (fake_products_database.rb):

class FakeProductsDatabase
  def self.store(product)
    puts [product.name, product.description, product.price].join(' - ')
  end
end

Now, we will create a class that will be responsible for reading and handling file containing products data (dsl/data_importer.rb):

module Dsl
  class DataImporter
    module Syntax
      def add_product(&block)
        FakeProductsDatabase.store product(&block)
      end

      private

      def product(&block)
        ProductBuilder.new.tap { |b| b.instance_eval(&block) }.product
      end
    end

    include Syntax

    def self.import_data(file_path)
      new.instance_eval File.read(file_path)
    end
  end
end

The class has a method named import_data which expects a file path as an argument. The file is being read and the result is passed to the instance_eval method which is called on the class instance. What does it do? It evaluates the string as a Ruby code within the instance context. This means self will be the instance of DataImporter class. Thanks to the fact we are able to define desired syntax/keywords (for a better readability the syntax is defined as a module). When the add_product method is called the block given for the method is evaluated by ProductBuilder instance which builds Product instance. ProductBuilder class is described below (dsl/product_builder.rb):

module Dsl
  class ProductBuilder
    ATTRIBUTES = %i[name description price].freeze

    attr_reader :product

    def initialize
      @product = Product.new
    end

    ATTRIBUTES.each do |attribute|
      define_method(attribute) do |arg = nil, &block|
        value = block.is_a?(Proc) ? block.call : arg
        product.public_send("#{attribute}=", value)
      end
    end
  end
end

The class defines syntax allowed within add_product block. With a bit of metaprogramming it adds methods which assign values to product attributes. These methods also support passing a block instead of a direct value, so a value can be calculated at runtime. Using attribute reader, we are able to obtain a built product at the end.

Now, let’s add the import script (import_job.rb):

require_relative 'dsl/data_importer'
require_relative 'dsl/product_builder'
require_relative 'fake_products_database'
require_relative 'product'

Dsl::DataImporter.import_data(ARGV[0])

And finally – using our DSL – a file with products data (dataset.rb):

add_product do
  name 'Charger'
  description 'Life saving'
  price 19.99
end

add_product do
  name 'Car wreck'
  description { "Wrecked at #{Time.now.strftime('%F %T')}" }
  price 0.01
end

add_product do
  name 'Lockpick'
  description 'Doors shall not close'
  price 7.50
end

To import the data we just need to execute one command:

ruby import_job.rb dataset.rb

And the result is..

Charger - Life saving - 19.99
Car wreck - Wrecked at 2018-12-09 09:47:42 - 0.01
Lockpick - Doors shall not close - 7.5

..success!

Conclusion

By looking at the all examples above, it is not hard to notice the possibilities offered by DSL. DSL allows to simplify some routine operations by hiding all required logic behind and exposing to user only the most important keywords. It allows you to get a higher level of abstraction and offers flexible use possibilities (what is especially valuable in terms of reusability). On the other hand, adding DSL to your project should be always well considered – an implementation using metaprogramming is definitely much harder to understand and maintain. Moreover, it requires solid tests suite due to its dynamism. Documenting DSL furthers its easier understanding, so it is definitely worth doing. Although implementing your own DSL can be rewarding, it is good to remember that it must pay off.

Did you get interested in the topic? If so let us know – we will tell you about DSL which we have recently created to meet the requirements in one of our projects.