How to write DSL (with Acts As Tree in background)

Acts As Tree is useful plugin providing abstract layer for Your models to form tree structures. I was using it recently to store some document structures in DB. Plugin works as a charm and there is not too much to write about. Just follow README ;)

tree.jpg

But I needed to create documents somehow, and provide easy way to upload them to DB during migrations. SQL is not a solution as is fragile to schema changes. Write definitions in pure Ruby results in long, unreadable and error prone code.

So what to do? Write DSL to handle this task. By the way – writing DSLs is one of killer features of Ruby, IMO.

Why not use something like hpricot and parse HTML source and create tree? Well I think that in this case – limited subset of tags to implement and some additional logic which is not always easy extractable from HTML that approach was simpler. Output from some nodes is complex, not single tag, so parsing it from HTML source is more complicated.

Or to say it other way – I started with DSL, it took 1 hr to write it and DSL helped to get job done, so… It was good enough for me.

So here it comes – example how You can create DSL for own application. This was code for prototype application, so for sure it can be done in some more polished way. I wait for some comments, what You would change.

But first word about tree nodes. Since I needed to store different types of elements I decided to store in tree nodes class name of final classes and arguments to for initialize call. That way tree structure is independent from nodes types. As a contra there is overhead for creation of final object (first we need to fetch and create AR object for tree node and then create final object). But in this case result from tree generation is subject to caching (since it is not changing) so it is OK. I will write more about how I was using this structure in separate post.

Where to start with DSL?


I think that best is to imagine how ideal DSL example should look like. In that case I wanted to get definition of tree similar to this:

div(:css_class => 'main') {
  div(:css_class => 'left') {
     img(:width => 300) 
     img(:width => 300)
   }
   div(:css_class => 'right') {
      fancy_control(:param => 102, :seed => 30)
      paragraph
   }
  
}

This DSL supposed to create document which can be rendered into HTML, XML or for some other formats (PDF, PostScript – whatever You would need). Again – how to get other formats from tree is topic for other post.

Using {} as grouping feature builds real structure. So in example above first we create container (div) which contains two sub-containers (div’s), first with two images, and second with some more complicated control and text paragraph.

Building DSL

To avoid some name collisions we build DSL as module.

module OurDSL

  @@ids_stack = []
  
  def create_node(name, opts, &block)
    ob = TreeNode.new
    ob.klass = name
    args = []
    opts.each {|k,v|
      args << ":#{k} => '#{v}'"
    }
    ob.args = args.join ","
    parent_id = @@ids_stack.last
    ob.parent_id = parent_id
    ob.save!
    if block_given?
      @@ids_stack << ob.id
      yield
      @@ids_stack.pop
    ob.id
  end
  
end

And some minimalistic model definition (with Annotate Models plugin output):

# Table name: tree_nodes
#
#  id          :integer(11)     not null, primary key
#  klass       :string(255)     
#  name        :string(255)     
#  args        :text            
#  parent_id   :integer(11)     
#  created_at  :datetime        
#  updated_at  :datetime        
#  children_order :integer(11)     default(0), not null
#

class TreeNode < ActiveRecord::Base
  acts_as_tree :order => 'children_order'
end

Now create_node works as follow: it takes two args (real name of class which will be representing this node and hash with arguments for initialize when node will be created for real) and optional code block. Then (lines 6-7) creates new TreeNode object, and stores first argument as class name.

Depending on how You plan to deal with errors in DSL probably it is worth to check if this class in known to application in this place. Since I provide wrappers to create nodes I decided not to check.

Next (lines 8-12) hash of arguments is converted into string suitable as initialize argument. Here is dangerous place in line 10. If options passed will use apostrophe inside quote will be broken!

Next code block (if it is given) is yielded and children are created. And here is one issue with Acts As Tree. When creating object and wanting to place in tree structure we need to know it's parent ID or use methods from parent to create children (parent.children.create). In both cases we need to pass somehow parent object (or it's ID) to block. I used class variable (module with DSL is meant to be included into class) with stack holding all parents ID up to root. Stack in Ruby it is just an array.

In lines 13-20 parent ID (or nil in case when @@ids_stack is empty) is stored in TreeNode, then object is saved. If block is given, current TreeNode ID is put on stack, block is yielded, ID is taken from the stack, and method returns ID of created object.

So as result will be returned root ID. Worth to mention is to say that DSL will fail with code like this:

div {
 img
 img
}

div {
 img
 img
}

Fail - or behave as it should, but it could be not expected ;) It will create two disconnected trees with div' as it root nodes.

Add some wrappers

Now some wrappers:

  def div(opts={}, &block)
    create_node( "ContentContainer", opts, &block )
  end
  
  def img(opts={}, &block)
    create_node( "Image", opts, &block )
  end

ContentContainer and Image are classes which represent objects which will be real logic placeholder.

DSL - other things

With DSL creation there is often handy to use method_missing as way to avoid wrappers. Another thing is to think about security - DSL's like that run in context of Your application are powerful tool, but as such it can hurt You.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.