Always fun, deciphering Ruby DSLs (part 1)

I never back down from a new system or programming language and thankfully my daily work has exposed me to some Ruby and Rails code. So I've been learning some, just enough to be able to tweak code, fix bugs and make minor additions. I'm not a fan of the Ruby on Rails design, but I enjoy it as a challenge.

One thing that I found difficult was writing code that uses some of the many DSLs created by the community. My work has exposed me to Retire (an old ElasticSearch integration library), but now I just saw some code on ActiveAdmin's homepage and I thought I'll have some fun trying to understand it.

The code, taken verbatim from http://activeadmin.info/

# app/admin/products.rb
ActiveAdmin.register Product d

  # Create sections on the index screen
  scope :all, default: true
  scope :available
  scope :drafts

  # Filterable attributes on the index screen
  filter :title
  filter :author, as: :select, collection: ->{ Product.authors }
  filter :price
  filter :created_at

  # Customize columns displayed on the index screen in the table
  index do
    column :title
    column "Price", sortable: :price do |product|
      number_to_currency product.price
    end
    default_actions
  end

end

Let's start!

First line, first WTF:

ActiveAdmin.register Product do

I can't really tell if the do...end block is something that will be called on the Product class, or it's something that will be passed to the ActiveAdmin.register method call. I'll asume the block is passed to the register call.

Next line, it was a big WTF when I've first started working on my project. My usual learning method is to just start working and whenever I see something that I don't understand, I'll stop and research it. It makes for nice, strong impressions (smiley). Of course, what impresses is the ease with which those colons switch sides, left and right of the word. Let's just say Python doesn't have that. 

So, this line 

  scope :all, default: true

is translated to something like

scope(Symbol('all'), {Symbol('default'): True})

Even now that I had a vague idea what it means, it took me a bit to translate. Python doesn't have symbols, it is idiomatic to use strings instead, so it would be usually written as:

scope('all', {'default': True})

I don't think that's too bad. A lot more clear what happens. I initially thought that :strings are just a Ruby way of not writing quotes, but a few hours spent trying to understand why Ruby on Rails breaks for no apparent reason somewhere deep inside taught me, the hard way, that those are magic names that can be just created on the spot, no need to import them and they'll always match if the given name is identical. AKA symbols. Worst part is when you need to do somestrvar.tosym just to be able to test some matches, when that string comes from "the outside".

I don't really like that code can be written in multiple ways, each with its own intricacies. This simplified way of declaring a hashmap in the method parameters can only be used as last parameter? Just a sample from an interactive irb session:

irb(main):001:0> def x (a, b) puts a; puts b; end
irb(main):002:0> x :all, default: true
all
{:default=>true}
=> nil
irb(main):003:0> x default: true, :all
SyntaxError: (irb):003: syntax error, unexpected '\n', expecting =>
        from /usr/bin/irb:11:in `<main>'

Moving on... multiple times calling the same method? Why???

  scope :all, default: true
  scope :available
  scope :drafts

Without reading the code or the documentation, I feel that any of my guesses could be wrong. The comment above that code helps a bit:

  # Create sections on the index screen

ActiveAdmin Features So, after thinking, reading the homepage, looking at that pretty pic, my guess is: first line defines a new section on the "front page" that will show "all" products, and it will be the default section. The next two lines define two other sections that will filter "available" products and "drafts" products.

Next lines: the filter calls. First line, same old, but the second line... WTF? yet another Ruby syntax kink?
filter :author, as: :select, collection: ->{ Product.authors }

Ok, ok, ok. "filter" is method name, the rest are parameters. First argument, :author, that's a symbol. After that comes a hashmap with two members, at keys :as and :collection. The weirdest is that -> sign and the accolades. That will be an inline anonymous block, but I've only understood that by replicating this code in irb:

irb(main):034:0> x :first, as: 'blabla', default: ->{1}    
first

{:as=>"blabla", :default=>#<Proc:0x000000014d81a8@(irb):34 (lambda)>}

I'm guessing the reason that it cannot simply pass "Product.authors" to collection is that ruby will call the .authors method (as there's no attribute access in Ruby, only methods call). Is there a way to pass a Ruby method without calling it, without defining a lambda? Who knows, maybe I'll find out at one point.

And the last method call. Ruby blocks are strange for me. I get them, I grok them, but they're just opaque. "Hey you, method there, here is some code, take it and 'eval' it in your scope. Don't worry, I promise it will be cool". Meh...  

  # Customize columns displayed on the index screen in the table
  index do
    column :title
    column "Price", sortable: :price do |product|
      number_to_currency product.price
    end
    default_actions
  end

But we're already inside a block started at first line, "ActiveAdmin.register Product do". So where "scope", "filter" and "index" come from? I'm guessing they belong to the ActiveAdmin class?

As I've already mentioned, I can't understand Ruby DSLs and my lack of experience with the language means that I always end up having to read the code. No documentation could replace that, in my experience, for this particular case.

So, reading through the active_admin.rb file. WTF?

class << self

Ah... so there's a module as the parent level, now we're defining a class that "inherits???" self? So, aparently just regular Ruby business: http://stackoverflow.com/questions/2505067/class-self-idiom-in-ruby As far as I can understand, we're adding the code that follows (from inside the class definition) to the module "class definition instance".

    attr_accessor :application

    def application
      @application ||= ::ActiveAdmin::Application.new
    end

So this ActiveAdmin module gets an instance variable called "application", accessible through the "application" accessor. It will be created, if it doesn't exist, by instantiating ActiveAdmin::Application. 

Next, another oddity:

delegate :register,      to: :application
We're delegating any calls to a method called "register" to the "application" object? It appears so http://api.rubyonrails.org/classes/Module.html#method-i-delegate
 
So, next to research is that Application class. Inside active_admin/application.rb we have the "register" method:
    # Registers a brand new configuration for the given resource.
    def register(resource, options = {}, &block)
      ns = options.fetch(:namespace){ default_namespace }
      namespace(ns).register resource, options, &block
    end

So, in our "ActiveAdmin.register Product do" line, we've passed resource as Product, no options, but we did pass a block, specified here as a reference to a block. They are further passed to a namespace construction. After more indirection, going through active_admin/namespace.rb with 

parse_registration_block(config, resource_class, &block) if block_given?

we get to this

      config.dsl = ResourceDSL.new(config, resource_class)
      config.dsl.run_registration_block(&block)

run_registration_block just evaluates the block in the dsl context. And we finally get to see what the index call does:

    # Configure the index page for the resource
    def index(options = {}, &block)
      options[:as] ||= :table
      config.set_page_presenter :index, ActiveAdmin::PagePresenter.new(options, &block)
    end

Another indirection. We need to look at PagePresenter. Which just stores the options... and actually the set_page_presenter pottentially does some stuff?

to be continued...

Comments