A DSL for making SQL-like DSLs
Posted by matijs 14/04/2007 at 14h42
Setting the stage
Some time ago, I came across SQLDSL, a DSL for building SQL queries. The benefit of using a DSL over plain old string concatenation is that syntax is checked before the database server is hit. Unfortunately, SQLDSL does not deliver. It will happily accept
q = Insert.into[“frot”][“zop”][“blob”].values(“kng”).values[“kgn”]
resulting in
` q.to_sql
=> “insert into ‘frot’ (zop) (blob) values (‘kng’) values () (kgn)”
`
which is hardly acceptable SQL.
A metasolution
The problem is that each chained call operates on the same class, so the calls can be repeated, even when that’s not appropriate. Instead, each step in the building of the query should return an object of a different class, having only methods appropriate at that stage. Something like this:
` class Insert
[…]
def [](*fields)
return InsertIntoFields.new(@table, fields)
end
end
class InsertIntoFields
[…]
def values
InsertIntoFieldsValuesPre.new(@table, @fields)
end
end
etc.
`
Of course, this quickly becomes tedious, with all the initialize
methods having to be made, etc. Boring, boring. Let’s do some metaprogramming.
First, I need a quick way to create a class with a certain number of instance variables, that are set in the call to new()
. That sounds like Struct
, but the classes that creates have too many methods that would conflict with a DSL for SQL (like values()
). So instead, I took a small, simplified part of Mauricio Fernandez alternative: SuperClass (specifically, I didn’t want any accessors for the fields, or named parameters):
module SuperClass def self.new(*fields, &blk) k = Class.new do defined_instance_variables = fields.clone class << self; attr_reader :defined_instance_variables end define_method(:initialize) do |*a| if fields.size != a.size raise ArgumentError, "wrong number of arguments (#{a.size} for #{fields.size})" else fields.each_with_index {|f,i| instance_variable_set("#{f}", a[i])} end end end k.instance_eval &blk if blk k end end
(Annoyingly, there’s no way to dynamically create methods with arbitrary arity, without resorting to eval
-ing a string.)
The heart of this metaprogramming is this module:
module DSLChain def self.create(*fields, &blk) k = SuperClass.new(*fields) k.extend(DSLChain::Link) k.instance_eval &blk if blk k end module Link def add_step(func, field=nil) div = defined_instance_variables fields = div.clone fields << field unless field.nil? n = DSLChain.create(*fields) define_method func do |a| vals = [] fields.each do|f| if div.include?(f) vals << instance_variable_get(“@#{f}”) end end vals << a if a.size > 0 n.new((vals)) end n end end end
Stepper::Step#add_step(func, field)
adds a method called func
that creates an object of a new anonymous class, optionally adding a field called field
. It returns the new anonymous class, so theses calls can be chained.
This means we can do the following:
`
Insert …
Insert = DSLChain.create(:table) do Insert.into creates an instance of this class. def self.into(table) self.new(table) end
Insert.into[fields].values[values]
self.
add_step(:[], :fields).
add_step(:values).
add_step(:[], :values).class_eval do
def to_sql
cn = table.column_names
@fields.each {|f| cn.include?(f) or raise "Invalid field: #{f}"}
raise "Incorrect number of values" unless @values.size == @fields.size
res = "insert into #{table.to_s.downcase} ("
res << @fields.join(", ")
res << “) values (”
res << @values.map{|v| v.to_sql}.join(", ")
res << “)”
res
end
end
end `
In the middle there, each call to add_step(func, field)
creates a new intermediate anonymous class that results from a call to func
on the previous one. The result is that after creating an Insert
object with Insert.into
, the call sequence is forced to be
Insert.into(SomeTable)[“some”, “column”, “names”]. values[“these”, “are”, “values”].to_sql
Aside One: For this to work, Insert.into
takes some object that has a method column_names
, like ActiveRecord
objects. I’m also assuming appropriate to_sql
methods to be defined on String
and Numeric
)
Aside Two: I changed the syntax a little bit from SQLDSL, since I like it better that way.
The original example then results in:
` Insert.into(Frob)[“id”][“zonq”].values(“kng”).values[“kgn”]
=> undefined method `[]’ for #<#:0×30050220 @fields=[“id”], @table=Frob> (NoMethodError)
`
More examples
If you want two options, you can do this:
` Sample2 = DSLChain.create(:one) do k = self.add_step(:foo, :two) k.add_step(:bar, :three).class_eval do def to_s “A bar: #{@one}, #{@two}, #{@three}” end end k.add_step(:baz, :four).class_eval do def to_s “A baz: #{@one}, #{@two}, #{@four}” end end end Sample2.new(“one”).foo(“two”).bar(“three”).to_s
=> A bar: one, two, three
Sample2.new(“one”).foo(“two”).baz(“four”).to_s => A baz: one, two, four `
Where to from here?
This was mainly a proof-of-concept, of course. There is now a DSL to make DSLs. I haven’t even begun to create the DSL for the select
statement, and insert
is far from complete. Doing the conditions of the where
clause is yet another matter, although ez_where could prove useful for that.
On the other hand, I may not get round to adding all those parts. The metaprogramming was the best part, anyway.
On the gripping hand, wouldn’t it be nicer to have a DSL that actually looks like Ruby’s array operations, such as grep
and map
? That would be so much more beautiful.