tirelessly fighting for robotkind's right to singularity
04.09.2008 @ 12:37 AM CST
One of my favorite little elegances in Ruby on Rails is ActiveRecord's dynamic finder magic. It lets you perform simple model queries with as much readability as is conceivable in a method call:
Fortunately, a little experimentation, a covert glance at the ActiveRecord code, and a timely discovery of the wonders of inject led me to a successful implementation:
Much like ActiveRecord's dynamic finders, this bit of code accepts find_by and find_all_by methods with any variety of attribute names (as long as they're delimited by _and_, e.g. find_by_name_and_age). And while it doesn't accept an options hash for miscellaneous conditioning (yet?), it does have a few extras: you can query for the indices of matching elements instead of the elements themselves (e.g., find_index_by_name or find_indices_by_name), and it will match hash key/values in addition to methods.
Some example usage:
# Normal ActiveRecord call - tasty, but still a bit bland. Sandwich.find(:all, :conditions => ['meat = ? and tastiness = ?', 'turkey', 'medium']) # Dynamic finder - unequivocally delicious. Try it with chocolate! Sandwich.find_all_by_meat_and_tastiness('bacon', 'very')Lost in the wonders of such syntactic saltiness (er, sugariness), I frequently found myself using these kinds of commands on arrays of model objects I had already retrieved from the database. Naturally enough, all I encountered was a variety of colorful exceptions, but I continued to dream of a world where dynamic finders worked for arrays, too.
Fortunately, a little experimentation, a covert glance at the ActiveRecord code, and a timely discovery of the wonders of inject led me to a successful implementation:
class Array # Dynamic finder for Array class def method_missing(method, *args) # Get method match info; proceed only if a dynamic finder call super unless match = method.to_s.match( /^find_(by|all_by|index_by|indices_by)_([_a-zA-Z]\w*)/) # Determine finder type & attribute names/symbols based on method name finder = match.captures.first.to_sym finder_is_all = (finder == :all_by or finder == :indices_by) attr_names = match.captures.last.split('_and_') attr_symbols = attr_names.collect { |i| i.to_sym } attr_indices = (0..attr_names.size - 1) # Iterate through array elements, storing matches as we go (via 'inject') return (0..size-1).inject([]) do |matches, idx| el = self[idx] # Iterate through attribute names and match against hash values (if a hash, # attempting both string and symbol keys, since we can't distinguish them # from the dynamic method) or method names (using __send__) if el and attr_indices.all? { |attr_idx| (el.is_a?(Hash) and el[attr_symbols[attr_idx]] == args[attr_idx] || el[attr_names[attr_idx]] == args[attr_idx]) or (el.respond_to?(attr_symbols[attr_idx]) and el.__send__(attr_symbols[attr_idx]) == args[attr_idx]) } # Return first match (or index) unless this is a 'find all' command return (finder == :index_by ? idx : el) if !finder_is_all # If 'find all', add element to match array; otherwise, add index matches.push(finder == :all_by ? el : idx) else finder_is_all ? matches : nil # Return state of matches array for 'inject' purposes end end end endI'm sure there is some ridiculous way to rubyify this into three lines of code, but in the spirit of what this code does (making code prettier), I figured I'd avoid the obvious potential for irony.
Much like ActiveRecord's dynamic finders, this bit of code accepts find_by and find_all_by methods with any variety of attribute names (as long as they're delimited by _and_, e.g. find_by_name_and_age). And while it doesn't accept an options hash for miscellaneous conditioning (yet?), it does have a few extras: you can query for the indices of matching elements instead of the elements themselves (e.g., find_index_by_name or find_indices_by_name), and it will match hash key/values in addition to methods.
Some example usage:
# Define an array of hashes (with name, birth year, and state entries) hash_citizens = [{:name => 'Mike', :birth_year => 1982, :state => :illinois}, {:name => 'Barack', :birth_year => 1961, :state => :illinois} # Define an array of Citizen model objects (with attributes matching entries defined in hashes above) model_citizens = [Citizen.new(:name => 'Mike', :birth_year => 1982, :state => :illinois), Citizen.new(:name => 'Barack', :birth_year => 1961, :state => :illinois)] # Hash key/values will be matched against names supplied in method - :name (or 'name') for find_by_name, etc. # Any other objects will be matched using messages (method calls) hash_citizens.find_by_name('Mike') model_citizens.find_by_name('Mike') # Multiple matches will only be returned if 'all' is present in method name model_citizens.find_by_state(:illinois) # Returns the first match only model_citizens.find_all_by_state(:illinois) # Returns all matches in array, in order # Indexes are returned similarly model_citizens.find_index_by_name('Barack') # Returns 1, the position of 'Barack' in the array model_citizens.find_indices_by_state(:illinois) # Returns [0,1] # If no matches are found, returns nil (singular calls) or empty array ('all' calls) model_citizens.find_by_other_stuff # Returns nil model_citizens.find_all_by_other_stuff # Returns empty arrayI've written 40 or so tests for these routines, so they should work properly enough, but do let me know if you spot any issues. And as always, any other comments or suggestions would also be greatly appreciated!
