|
Joel Spolsky writes about leaky abstractions.
Abstractions leak when the underlying implementation shows through. A leaky
abstractions that have always bothered me is moving from a small collection
of objects to a database backed collection.
Here’s an example. Suppose you had a list of person objects, and you
wanted to extract everybody under the age of 21. You might write some code
like this.
# ARRAY VERSION
youngsters = people.select { |p| p.age < 21 }
This works great with small in-memeory collections. But what happens when
you start storing your objects in a database. Fetching every row from the
database and running the comparison on the is not only slow, it defeats the
purpose of using a database. So suddenly your code becomes …
# SQL VERSION
youngsters = people.select "person.age < 21"
We pass in a string (instead of a block) so we can use the string to build
a SQL query. Somewhere buried inside of the select method is a statement
that looks something like this:
def select(query_string)
sql = "SELECT * FROM person WHERE #{query_string}"
# Use the SQL string to query the database
end
We have to switch to string encoded queries because we have a leaky
abstraction.
Ryan Pavlik published an interesting Ruby library, called Criteria, that
helps to plug this particular leak. Ryan’s Criteria library provides
table objects that work like this …
require 'criteria/sql'
table = Criteria::SQLTable.new("person")
query = table.age < 21
puts query.select # => "SELECT * FROM person WHERE (person.age < 21)"
Wow. Did you see what just happened? We took an ordinary Ruby expression
(table.age < 21) and somehow captured it in data — data that we
used to generate an SQL statment. Lets skip how this works for
just a moment and consider what we can do with this.
Using Ryan’s criteria, we can now write a database backed collection
that doesn’t require us to pass in SQL fragments to do arbitrary
queries. Instead we can express our queries in natural Ruby syntax and let
the library handle the conversion.
A collection that takes advantage of Criteria might look something like
this (in part):
class People
def initialize(db)
@db = db # DBI database handle
end
def select(&block)
table = Criteria::SQLTable.new("person")
query = block.call(table)
@db.select_all(query.select).collect { |row|
Person.new(row['name'], row['age'])
}
end
end
How it Works
The mechanism behind Criteria is surprisingly simple. It parses the
expression by executing it. Sending any message to a table object will
cause it to remember that message in a special criterion object, which is
returned as the result of the message. Futher messages to the criterion
object are also recorded and new criterion objects are returned. The end
result is a network of criterion objects that resemble the parse tree for
the expression being evaluated. Once you have that parse tree, the rest is
easy.
Remaining Leaks
The Ruby code to generate the parse tree is about 50 lines of code, a real
tribute to the flexibility of Ruby. However, it is not perfect. Since the
library depends on recording messages, anything that is not a message will
be lost. Since almost everything in Ruby (including operators) send
messages, this is not a problem — except for the short circuit logic
operators && and ||. So there’s one leak, Criteria
expressions need to use & and | instead of the more natural &&
and || operators.
The second leak deals with how types are coerced in Ruby. The expression
t.age < 21 will work because the less than message is sent to
the table object and it knows how to handle it. However, the expression
21 > t.age will send the message to the integer object and it
doesn’t know how to handle tables.
Fortunately the restrictions are fairly mild. The Criteria library
represents some wonderful "outside the box" thinking to attack a
particularly difficult problem.
comments
|