You have to write fragments of SQL to pass parameters into an ActiveRecord query. Youd like to dispense with SQL altogether, and represent the query paramaters as a Ruby data structure.
Heres a simple solution. The method ActiveRecord::Base.find_by_map defined below picks up where find leaves off. Normally a query is represented by a SQL fragment, passed in as the :conditions argument. Here, the :conditions argument contains a mapping of database field names to the desired values:
require cookbook_dbconnect
class ActiveRecord::Base
def self.find_by_map(id, args={}.freeze)
sql = []
values = []
args[:conditions].each do |field, value|
sql << "#{field} = ?"
values << value
end if args[:conditions]
args[:conditions] = [sql.join( AND ), values]
find(id, args)
end
end
Heres find_by_map in action, using the BlogPost class first seen in Recipe 13.11:
activerecord_connect
class BlogPost < ActiveRecord::Base
end
BlogPost.create(:title => Game Review: Foosball Carnage,
:content => Four stars!)
BlogPost.create(:title => Movie Review: Foosball Carnage: The Movie,
:content => ero stars!)
BlogPost.find_by_map(:first,
:conditions => {:title =>
Game Review: Foosball Carnage }
).content
# => "Four stars!"
ActiveRecord saves you from having to write a lot of SQL, but you still have to write out the equivalent of a SQL WHERE clause every time you call ActiveRecord::Base#find. The find_by_map method lets you define those queries as Ruby hashes.
But find_by_map only lets you run one type of query: the kind where you e restricting fields of the database to specific values. What if you want to do a query that matches a field with the LIKE construct, or combine multiple clauses into a single query with AND or OR?
A hash can only represent a very simple SQL query, but the Criteria object, below, can represent almost any WHERE clause. The implementation is more complex but the idea is the same. We define a data structure that can represent the WHERE clause of a SQL query, and a way of converting the data structure into a real WHERE clause.
Heres the basic class. A Criteria acts like a hash, except it maps a field name to a value and a SQL operator. Instead of mapping :title to Game Review: Foosball Carnage, you can map it to [%Foosball%, LIKE]. Each Criteria object can be chained to other objects as part of an AND or OR clause.
class Criteria < Hash
def initialize(values)
values.each { |k,v| add(k, *v) }
@or_criteria = nil
@and_criteria = nil
end
:private
attr_accessor :or_criteria, :and_criteria
:public
def add(field, value, operation==)
self[field] = [value, operation]
end
def or(criteria)
c = self
while c.or_criteria != nil
break if c == criteria
c = c.or_criteria
end
c.or_criteria = criteria
return self
end
def and(criteria)
c = self
while c.and_criteria != nil
break if c == criteria
c = c.and_criteria
end
c.and_criteria = criteria
return self
end
This method turns a Criteria object, and any other objects to which its chained, into a SQL string with substitutions, and an array of values to use in the substitutions:
class Criteria
def to_where_clause
sql = []
values = []
each do |field, value|
if value.respond_to? :to_str
value, operation = value, =
else
value, operation = value[0..1]
end
sql << "#{field} #{operation} ?"
values << value
end
sql = ( + sql.join( AND ) + )
if or_criteria
or_where = or_criteria.to_where_clause
sql = "(#{sql} OR #{or_where.shift})"
values += or_where
end
if and_criteria
and_where = and_criteria.to_where_clause
sql = "(#{sql} AND #{and_where.shift})"
values += and_where
end
return values.unshift(sql)
end
end
Now its simple to write a version of find that accepts a Criteria:
class ActiveRecord::Base
def self.find_by_criteria(id, criteria, args={}.freeze)
args = args.dup
args[:conditions] = criteria.to_where_clause
find(id, args)
end
end
Heres Criteria used to express a complex SQL WHERE clause with a little bit of Ruby code. This query searches the blog_post table for reviews of bad movies and good games. The movies and the games must not be about the game of cricket.
review = Criteria.new(:title => [\%Review%, LIKE])
bad_movie = Criteria.new(:title => ["%Movie%", LIKE],
:content => ero stars!)
good_game = Criteria.new(:title => [\%Game%, LIKE],
:content => Four stars!)
no_cricket = Criteria.new(:title => [\%Cricket%, NOT LIKE])
review.and(bad_movie.or(good_game)).and(no_cricket)
review.to_where_clause
# => ["((title LIKE ?) AND
# (((content = ? AND title LIKE ?) OR (content = ? AND title LIKE ?))
# AND (title NOT LIKE ?)))",
# "%Review%", "Zero stars!", "%Movie%", "Four stars!", "%Game%",
# "%Cricket%"]
BlogPost.find_by_criteria(:all, review).each { |post| puts post.title }
# Game Review: Foosball Carnage
# Movie Review: Foosball Carnage: The Movie
The technique is a general one. Its easier for a human to construct Ruby data structures than to write valid SQL clauses, so write code to convert the one into the other. You can use this technique wherever any library expects you to write SQL.
For instance, the find method expects SQL fragments representing a querys ORDER BY or GROUP BY clause. You could represent each as an array of fields, and generate the SQL as needed.
# Just an idea… order_by = [[:title, ASC]]
Strings
Numbers
Date and Time
Arrays
Hashes
Files and Directories
Code Blocks and Iteration
Objects and Classes8
Modules and Namespaces
Reflection and Metaprogramming
XML and HTML
Graphics and Other File Formats
Databases and Persistence
Internet Services
Web Development Ruby on Rails
Web Services and Distributed Programming
Testing, Debugging, Optimizing, and Documenting
Packaging and Distributing Software
Automating Tasks with Rake
Multitasking and Multithreading
User Interface
Extending Ruby with Other Languages
System Administration