AdvancedSearch

Ruby database query builder for advanced search. For example, library catalog, employee phonebook, non-aggregate reports.

  • Simple, not easy. You'll have to write substantial code to get started, but future changes will be simple.
  • Requires basic understanding of graph theory, must know what a tree is.
  • Agnostic: Support for specific databases and ORMs is provided via plugins.
  • No dependencies
  • Unit tests of your search objects do not need to touch database, so they are very fast.

Example 1: Employee Phonebook

Given a Hash of parameters from an HTTP form, build a search query.

# You write one of these classes for each search form in your application.
# You write this class however you want to. This is one pattern I like.
class PhonebookSearch
  def initialize
    # We will be building a tree, and the head node is an N-ary
    # conjunction. Conjunction is the most common type of search. Disjunction
    # is also common.
    @head = ::AdvancedSearch::Nodes::And.new

    # Later, we'll traverse that tree using a visitor that knows how to build
    # a query for use with the mysql2 gem. Many other visitors are available.
    # If you switch databases in the future, you only change the visitor, you
    # don't change anything else in this class.
    @visitor = ::AdvancedSearch::Visitors::Mysql2.new
  end

  # Example params:
  #
  # {
  #   age_lt: 40,
  #   ssn_eq: '123-45-6789'
  # }
  #
  # You can organize your parameters, and name them, however you want. A Hash
  # is common.
  def search(params)
    build_tree(params)
    build_query
  end

  private

  # Build a tree by dynamically `send`ing the parameter name. Given the params
  # above, our tree will look like this:
  #
  #            and
  #         /       \
  #        lt        eq
  #       /  \      /  \
  #     age  40   ssn   '123-45-6789'
  #
  def build_tree(params)
    params.each { |k, v| send(k, v) }
  end

  # You write one method like this for each filter on your search form. After
  # this, our tree looks like:
  #
  #        and
  #         |
  #        lt
  #       /  \
  #     age  40
  #
  def age_lt(v)
    eq = ::AdvancedSearch::Nodes::Lt.new
    eq.add_edge(::AdvancedSearch::Nodes::Id.new(:age))
    eq.add_edge(::AdvancedSearch::Nodes::Value.new(v))
    @head.add_edge(eq)
  end

  def ssn_eq(v)
    eq = ::AdvancedSearch::Nodes::Eq.new
    eq.add_edge(::AdvancedSearch::Nodes::Id.new(:ssn))
    eq.add_edge(::AdvancedSearch::Nodes::Value.new(v))
    @head.add_edge(eq)
  end

  # Returns a query suitable for use with the mysql2 gem, because that's the
  # visitor we selected above.
  def build_query
    @head.accept(@visitor)
    @visitor.result
  end
end