class Pursuit::Search

Attributes

options[R]

@return [SearchOptions] The options to use when building the search query.

Public Class Methods

new(options) click to toggle source

Create a new instance to search a specific ActiveRecord record class.

@param options [SearchOptions]

# File lib/pursuit/search.rb, line 13
def initialize(options)
  @options = options
end

Public Instance Methods

perform(query) click to toggle source

Perform a search for the specified query.

@param query [String] The query to transform into a SQL search. @return [ActiveRecord::Relation] The search results.

# File lib/pursuit/search.rb, line 22
def perform(query)
  options.record_class.where(build_arel(query))
end

Private Instance Methods

build_arel(query) click to toggle source
# File lib/pursuit/search.rb, line 28
def build_arel(query)
  parser = SearchTermParser.new(query, keys: options.keys)
  unkeyed_arel = build_arel_for_unkeyed_term(parser.unkeyed_term)
  keyed_arel = build_arel_for_keyed_terms(parser.keyed_terms)

  if unkeyed_arel && keyed_arel
    unkeyed_arel.and(keyed_arel)
  else
    unkeyed_arel || keyed_arel
  end
end
build_arel_for_belongs_to_reflection_join(reflection) click to toggle source
# File lib/pursuit/search.rb, line 114
def build_arel_for_belongs_to_reflection_join(reflection)
  reflection_table = reflection.klass.arel_table
  reflection_table.where(
    reflection_table[reflection.join_primary_key].eq(
      options.record_class.arel_table[reflection.join_foreign_key]
    )
  )
end
build_arel_for_has_reflection_join(reflection) click to toggle source
# File lib/pursuit/search.rb, line 123
def build_arel_for_has_reflection_join(reflection)
  reflection_table = reflection.klass.arel_table
  reflection_through = reflection.through_reflection

  if reflection_through.present?
    # :has_one through / :has_many through
    reflection_through_table = reflection_through.klass.arel_table
    reflection_table.join(reflection_through_table).on(
      reflection_through_table[reflection.foreign_key].eq(reflection_table[reflection.klass.primary_key])
    ).where(
      reflection_through_table[reflection_through.foreign_key].eq(
        options.record_class.arel_table[options.record_class.primary_key]
      )
    )
  else
    # :has_one / :has_many
    reflection_table.where(
      reflection_table[reflection.foreign_key].eq(
        options.record_class.arel_table[options.record_class.primary_key]
      )
    )
  end
end
build_arel_for_keyed_terms(terms) click to toggle source
# File lib/pursuit/search.rb, line 50
def build_arel_for_keyed_terms(terms)
  return nil if terms.blank?

  terms.reduce(nil) do |chain, term|
    attribute_name = term.key.to_sym
    reflection = options.relations.key?(attribute_name) ? options.record_class.reflections[term.key] : nil
    node = if reflection.present?
             attribute_names = options.relations[attribute_name]
             build_arel_for_reflection(reflection, attribute_names, term.operator, term.value)
           else
             node_builder = options.keyed_attributes[attribute_name]
             build_arel_for_node(node_builder.call, term.operator, term.value)
           end

    chain ? chain.and(node) : node
  end
end
build_arel_for_node(node, operator, value) click to toggle source
# File lib/pursuit/search.rb, line 68
def build_arel_for_node(node, operator, value)
  sanitized_value = ActiveRecord::Base.sanitize_sql_like(value)
  sanitized_value = sanitized_value.to_i if sanitized_value =~ /^[0-9]+$/

  case operator
  when '>'   then node.gt(sanitized_value)
  when '>='  then node.gteq(sanitized_value)
  when '<'   then node.lt(sanitized_value)
  when '<='  then node.lteq(sanitized_value)
  when '*='  then node.matches("%#{sanitized_value}%")
  when '!*=' then node.does_not_match("%#{sanitized_value}%")
  when '!='  then node.not_eq(sanitized_value)
  when '=='
    if value.present?
      node.eq(sanitized_value)
    else
      node.eq(nil).or(node.eq(''))
    end
  else
    raise "The operator '#{operator}' is not supported."
  end
end
build_arel_for_reflection(reflection, attribute_names, operator, value) click to toggle source
# File lib/pursuit/search.rb, line 91
def build_arel_for_reflection(reflection, attribute_names, operator, value)
  nodes = build_arel_for_reflection_join(reflection)
  count_nodes = build_arel_for_relation_count(nodes, operator, value) unless reflection.belongs_to?
  return count_nodes if count_nodes.present?

  match_nodes = attribute_names.reduce(nil) do |chain, attribute_name|
    node = build_arel_for_node(reflection.klass.arel_table[attribute_name], operator, value)
    chain ? chain.or(node) : node
  end

  return nil if match_nodes.blank?

  nodes.where(match_nodes).project(1).exists
end
build_arel_for_reflection_join(reflection) click to toggle source
# File lib/pursuit/search.rb, line 106
def build_arel_for_reflection_join(reflection)
  if reflection.belongs_to?
    build_arel_for_belongs_to_reflection_join(reflection)
  else
    build_arel_for_has_reflection_join(reflection)
  end
end
build_arel_for_relation_count(nodes, operator, value) click to toggle source
# File lib/pursuit/search.rb, line 147
def build_arel_for_relation_count(nodes, operator, value)
  node_builder = proc do |klass|
    count = ActiveRecord::Base.sanitize_sql_like(value).to_i
    klass.new(nodes.project(Arel.star.count), count)
  end

  case operator
  when '>'  then node_builder.call(Arel::Nodes::GreaterThan)
  when '>=' then node_builder.call(Arel::Nodes::GreaterThanOrEqual)
  when '<'  then node_builder.call(Arel::Nodes::LessThan)
  when '<=' then node_builder.call(Arel::Nodes::LessThanOrEqual)
  else
    return nil unless value =~ /^([0-9]+)$/

    case operator
    when '=='  then node_builder.call(Arel::Nodes::Equality)
    when '!='  then node_builder.call(Arel::Nodes::NotEqual)
    when '*='  then node_builder.call(Arel::Nodes::Matches)
    when '!*=' then node_builder.call(Arel::Nodes::DoesNotMatch)
    end
  end
end
build_arel_for_unkeyed_term(value) click to toggle source
# File lib/pursuit/search.rb, line 40
def build_arel_for_unkeyed_term(value)
  return nil if value.blank?

  sanitized_value = "%#{ActiveRecord::Base.sanitize_sql_like(value)}%"
  options.unkeyed_attributes.reduce(nil) do |chain, (attribute_name, node_builder)|
    node = node_builder.call.matches(sanitized_value)
    chain ? chain.or(node) : node
  end
end