Rails-Style Dynamic Finders For Ruby Arrays

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:
# 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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
end
I’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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 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 array
I’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!

Leave a Reply

(required)

(required)