class TalentScout::ModelSearch

Public Class Methods

criteria(names, type = :string, choices: nil, **attribute_options, &block) click to toggle source

Defines criteria to incorporate into the search. Each criteria corresponds to an attribute on the search object that can be used when building a search form.

Each attribute has a type, just as Active Model attributes do, and values passed into the search object are typecasted before criteria are evaluated. Supported types are the same as Active Model (e.g. :string, :boolean, :integer, etc), with the addition of a :void type. A :void type is just like a :boolean type, except that the criteria is not evaluated when the typecasted value is falsey.

Alternatively, instead of a type, an Array or Hash of choices can be specified, and the criteria will be evaluated only if the passed-in value matches one of the choices.

Active Model attribute_options can also be specified. Most notably, the :default option provides the criteria a default value to operate on.

Each criteria can specify a block which recieves its corresponding typecasted value as an argument. If the corresponding value is not set on the search object, and no default value is defined, the criteria will not be evaluated. Like an Active Record scope block, a criteria block is evaluated in the context of an ActiveRecord::Relation, and should return an ActiveRecord::Relation. A criteria block may also return nil, in which case the criteria will be skipped. If no criteria block is specified, the criteria will be evaluated as a where clause using the criteria name and typecasted value.

As a convenient shorthand, Active Record scopes which have been defined on the {model_class} can be used directly as criteria blocks by passing the scope's name as a symbol-to-proc in place of the criteria block.

@example Implicit block

class PostSearch < TalentScout::ModelSearch
  criteria :title
end

PostSearch.new(title: "FOO").results  # == Post.where(title: "FOO")

@example Explicit block

class PostSearch < TalentScout::ModelSearch
  criteria :title do |string|
    where("title LIKE ?", "%#{string}%")
  end
end

PostSearch.new(title: "FOO").results  # == Post.where("title LIKE ?", "%FOO%")

@example Using an existing Active Record scope

class Post < ActiveRecord::Base
  scope :title_includes, ->(string){ where("title LIKE ?", "%#{string}%") }
end

class PostSearch < TalentScout::ModelSearch
  criteria :title, &:title_includes
end

PostSearch.new(title: "FOO").results  # == Post.title_includes("FOO")

@example Specifying a type

class PostSearch < TalentScout::ModelSearch
  criteria :created_on, :date do |date|
    where(created_at: date.beginning_of_day..date.end_of_day)
  end
end

PostSearch.new(created_on: "Dec 31, 1999").results
  # == Post.where(created_at: Date.new(1999, 12, 31).beginning_of_day..
  #                           Date.new(1999, 12, 31).end_of_day)

@example Using the void type

class PostSearch < TalentScout::ModelSearch
  criteria :only_edited, :void do
    where("modified_at > created_at")
  end
end

PostSearch.new(only_edited: false).results  # == Post.all
PostSearch.new(only_edited: "0").results    # == Post.all
PostSearch.new(only_edited: "").results     # == Post.all
PostSearch.new(only_edited: true).results   # == Post.where("modified_at > created_at")
PostSearch.new(only_edited: "1").results    # == Post.where("modified_at > created_at")

@example Specifying choices (Array)

class PostSearch < TalentScout::ModelSearch
  criteria :category, choices: %w[science tech engineering math]
end

PostSearch.new(category: "math").results  # == Post.where(category: "math")
PostSearch.new(category: "BLAH").results  # == Post.all

@example Specifying choices (Hash)

class PostSearch < TalentScout::ModelSearch
  criteria :within, choices: {
    "Last 24 hours" => 24.hours,
    "Past Week" => 1.week,
    "Past Month" => 1.month,
    "Past Year" => 1.year,
  } do |duration|
    where("created_at >= ?", duration.ago)
  end
end

PostSearch.new(within: "Last 24 hours").results  # == Post.where("created_at >= ?", 24.hours.ago)
PostSearch.new(within: 24.hours).results         # == Post.where("created_at >= ?", 24.hours.ago)
PostSearch.new(within: 23.hours).results         # == Post.all

@example Specifying a default value

class PostSearch < TalentScout::ModelSearch
  criteria :within_days, :integer, default: 7 do |num|
    where("created_at >= ?", num.days.ago)
  end
end

PostSearch.new().results                # == Post.where("created_at >= ?", 7.days.ago)
PostSearch.new(within_days: 2).results  # == Post.where("created_at >= ?", 2.days.ago)

@overload criteria(names, type = :string, **attribute_options, &block)

@param names [String, Symbol, Array<String>, Array<Symbol>]
@param type [Symbol, ActiveModel::Type]
@param attribute_options [Hash]
@option attribute_options :default [Object]
@yieldparam value [Object]
@yieldreturn [ActiveRecord::Relation, nil]

@overload criteria(names, type = :string, **attribute_options)

@param names [String, Symbol, Array<String>, Array<Symbol>]
@param type [Symbol, ActiveModel::Type]
@param attribute_options [Hash]
@option attribute_options :default [Object]

@overload criteria(names, choices:, **attribute_options, &block)

@param names [String, Symbol, Array<String>, Array<Symbol>]
@param choices [Array<String>, Array<Symbol>, Hash<String, Object>, Hash<Symbol, Object>]
@param attribute_options [Hash]
@option attribute_options :default [Object]
@yieldparam value [Object]
@yieldreturn [ActiveRecord::Relation, nil]

@overload criteria(names, choices:, **attribute_options)

@param names [String, Symbol, Array<String>, Array<Symbol>]
@param choices [Array<String>, Array<Symbol>, Hash<String, Object>, Hash<Symbol, Object>]
@param attribute_options [Hash]
@option attribute_options :default [Object]
@yieldreturn [ActiveRecord::Relation, nil]

@return [void] @raise [ArgumentError]

if +choices+ is specified and +type+ is not +:string+
# File lib/talent_scout/model_search.rb, line 244
def self.criteria(names, type = :string, choices: nil, **attribute_options, &block)
  if choices
    if type != :string
      raise ArgumentError, "Option :choices cannot be used with type #{type}"
    end
    type = ChoiceType.new(choices)
  elsif type == :void
    type = VoidType.new
  elsif type.is_a?(Symbol)
    # HACK force ActiveRecord::Type.lookup because datetime types
    # from ActiveModel::Type.lookup don't support multi-parameter
    # attribute assignment
    type = ActiveRecord::Type.lookup(type)
  end

  crit = Criteria.new(names, !type.is_a?(VoidType), &block)
  criteria_list << crit

  crit.names.each do |name|
    attribute name, type, attribute_options

    # HACK FormBuilder#select uses normal attribute readers instead
    # of `*_before_type_cast` attribute readers.  This breaks value
    # auto-selection for types where the two are appreciably
    # different, e.g. ChoiceType with hash mapping.  Work around by
    # aliasing relevant attribute readers to `*_before_type_cast`.
    if type.is_a?(ChoiceType)
      alias_method name, "#{name}_before_type_cast"
    end
  end
end
default_scope(&block) click to toggle source

Sets the default scope of the search. Like Active Record's default_scope, the scope here is specified as a block which is evaluated in the context of the {model_class}. Also like Active Record, multiple calls of this method will append to the default scope.

@example

class PostSearch < TalentScout::ModelSearch
  default_scope { where(published: true) }
end

PostSearch.new.results  # == Post.where(published: true)

@example Using an existing scope

class Post < ActiveRecord::Base
  scope :published, ->{ where(published: true) }
end

class PostSearch < TalentScout::ModelSearch
  default_scope(&:published)
end

PostSearch.new.results  # == Post.published

@yieldreturn [ActiveRecord::Relation] @return [void]

# File lib/talent_scout/model_search.rb, line 76
def self.default_scope(&block)
  i = criteria_list.index{|crit| !crit.names.empty? } || -1
  criteria_list.insert(i, Criteria.new([], true, &block))
end
model_class() click to toggle source

Returns the model class that the search targets. Defaults to a class with same name name as the search class, minus the “Search” suffix. The model class can also be set with {model_class=}. If the model class has not been set, and the default class does not exist, a NameError will be raised.

@example Default behavior

class PostSearch < TalentScout::ModelSearch
end

PostSearch.model_class  # == Post (class)

@example Override

class EmployeeSearch < TalentScout::ModelSearch
  self.model_class = Person
end

EmployeeSearch.model_class  # == Person (class)

@return [Class] @raise [NameError]

if the model class has not been set and the default class does
not exist
# File lib/talent_scout/model_search.rb, line 32
def self.model_class
  @model_class ||= self.superclass == ModelSearch ?
    self.name.chomp("Search").constantize : self.superclass.model_class
end
model_class=(model_class) click to toggle source

Sets the model class that the search targets. See {model_class}.

@param model_class [Class] @return [Class]

# File lib/talent_scout/model_search.rb, line 41
def self.model_class=(model_class)
  @model_class = model_class
end
model_name() click to toggle source

@!visibility private

# File lib/talent_scout/model_search.rb, line 46
def self.model_name
  @model_name ||= ModelName.new(self)
end
new(params = {}) click to toggle source

Initializes a ModelSearch instance. Assigns values from params to corresponding criteria attributes.

If params is a ActionController::Parameters, blank values are ignored. This behavior prevents empty search form fields from affecting search results.

@param params [Hash<String, Object>, Hash<Symbol, Object>, ActionController::Parameters] @raise [ActiveModel::UnknownAttributeError]

if +params+ is a Hash, and it contains an unrecognized key
Calls superclass method
# File lib/talent_scout/model_search.rb, line 382
def initialize(params = {})
  # HACK initialize ActiveRecord state required by ActiveRecord::AttributeMethods::BeforeTypeCast
  @transaction_state ||= nil

  if params.is_a?(ActionController::Parameters)
    params = params.permit(self.class.attribute_types.keys).reject!{|key, value| value.blank? }
  end
  super(params)
end
order(name, columns = nil, default: false, **options) click to toggle source

Defines an order that the search can apply to its results. Only one order can be applied at a time, but an order can be defined over multiple columns. If no columns are specified, the order's name is taken as its column.

Each order can be applied in an ascending or descending direction by appending a corresponding suffix to the order value. By default, these suffixes are +“.asc”+ and +“.desc”+, but they can be overridden in the order definition using the :asc_suffix and :desc_suffix options, respectively.

Order direction affects all columns of an order defintion, unless a column explicitly specifies +“ASC”+ or +“DESC”+, in which case that column will stay fixed in its specified direction.

To apply an order to the search results by default, use the :default option in the order definition. (Note that only one order can be designated as the default order.)

@see toggle_order

@example Single-column order

class PostSearch < TalentScout::ModelSearch
  order :title
end

PostSearch.new(order: :title).results        # == Post.order("title")
PostSearch.new(order: "title.asc").results   # == Post.order("title")
PostSearch.new(order: "title.desc").results  # == Post.order("title DESC")

@example Multi-column order

class PostSearch < TalentScout::ModelSearch
  order :category, [:category, :title]
end

PostSearch.new(order: :category).results        # == Post.order("category, title")
PostSearch.new(order: "category.asc").results   # == Post.order("category, title")
PostSearch.new(order: "category.desc").results  # == Post.order("category DESC, title DESC")

@example Multi-column order, fixed directions

class PostSearch < TalentScout::ModelSearch
  order :category, ["category", "title ASC", "created_at DESC"]
end

PostSearch.new(order: :category).results
  # == Post.order("category, title ASC, created_at DESC")
PostSearch.new(order: "category.asc").results
  # == Post.order("category, title ASC, created_at DESC")
PostSearch.new(order: "category.desc").results
  # == Post.order("category DESC, title ASC, created_at DESC")

@example Specifying direction suffixes

class PostSearch < TalentScout::ModelSearch
  order "Title", [:title], asc_suffix: " (A-Z)", desc_suffix: " (Z-A)"
end

PostSearch.new(order: "Title").results        # == Post.order("title")
PostSearch.new(order: "Title (A-Z)").results  # == Post.order("title")
PostSearch.new(order: "Title (Z-A)").results  # == Post.order("title DESC")

@example Default order

class PostSearch < TalentScout::ModelSearch
  order :created_at, default: :desc
  order :title
end

PostSearch.new().results                         # == Post.order("created_at DESC")
PostSearch.new(order: :created_at).results       # == Post.order("created_at")
PostSearch.new(order: "created_at.asc").results  # == Post.order("created_at")
PostSearch.new(order: :title).results            # == Post.order("title")

@param name [String, Symbol] @param columns [Array<String>, Array<Symbol>, nil] @param options [Hash] @option options :default [Boolean, :asc, :desc] (false) @option options :asc_suffix [String] (“.asc”) @option options :desc_suffix [String] (“.desc”) @return [void]

# File lib/talent_scout/model_search.rb, line 360
def self.order(name, columns = nil, default: false, **options)
  definition = OrderDefinition.new(name, columns, options)

  if !attribute_types.fetch("order", nil).equal?(order_type) || default
    criteria_options = default ? { default: definition.choice_for_direction(default) } : {}
    criteria_list.reject!{|crit| crit.names == ["order"] }
    criteria "order", order_type, criteria_options, &:order
  end

  order_type.add_definition(definition)
end

Private Class Methods

criteria_list() click to toggle source

@!visibility private

# File lib/talent_scout/model_search.rb, line 627
def self.criteria_list
  @criteria_list ||= self == ModelSearch ? [] : self.superclass.criteria_list.dup
end
order_type() click to toggle source

@!visibility private

# File lib/talent_scout/model_search.rb, line 632
def self.order_type
  @order_type ||= self == ModelSearch ? OrderType.new : self.superclass.order_type.dup
end

Public Instance Methods

each_choice(criteria_name, &block) click to toggle source

Iterates over a specified {criteria}'s defined choices. If the given block accepts a 2nd argument, a boolean will be passed indicating whether that choice is currently assigned to the specified criteria.

An Enumerator is returned if no block is given.

@example With block

class PostSearch < TalentScout::ModelSearch
  criteria :category, choices: %w[science tech engineering math]
end

search = PostSearch.new(category: "math")

search.each_choice(:category) do |choice, chosen|
  puts "<li class=\"#{'active' if chosen}\">#{choice}</li>"
end

@example Without block

class PostSearch < TalentScout::ModelSearch
  criteria :category, choices: %w[science tech engineering math]
end

search = PostSearch.new(category: "math")

search.each_choice(:category).to_a
  # == ["science", "tech", "engineering", "math"]

search.each_choice(:category).map do |choice, chosen|
  chosen ? "<b>#{choice}</b>" : choice
end
  # == ["science", "tech", "engineering", "<b>math</b>"]

@overload each_choice(criteria_name, &block)

@param criteria_name [String, Symbol]
@yieldparam choice [String]
@return [void]

@overload each_choice(criteria_name, &block)

@param criteria_name [String, Symbol]
@yieldparam choice [String]
@yieldparam chosen [Boolean]
@return [void]

@overload each_choice(criteria_name)

@param criteria_name [String, Symbol]
@return [Enumerator]

@raise [ArgumentError]

if +criteria_name+ is invalid, or the specified criteria does
not define choices
# File lib/talent_scout/model_search.rb, line 563
def each_choice(criteria_name, &block)
  criteria_name = criteria_name.to_s
  type = self.class.attribute_types.fetch(criteria_name, nil)
  unless type.is_a?(ChoiceType)
    raise ArgumentError, "`#{criteria_name}` is not a criteria with choices"
  end
  return to_enum(:each_choice, criteria_name) unless block

  value_after_cast = attribute_set[criteria_name].value
  type.mapping.each do |choice, value|
    chosen = value_after_cast.equal?(value)
    block.arity >= 2 ? block.call(choice, chosen) : block.call(choice)
  end
end
has_attribute?(name) click to toggle source

@!visibility private HACK Implemented by ActiveRecord but not ActiveModel. Expected by some third-party form builders, e.g. Simple Form.

# File lib/talent_scout/model_search.rb, line 613
def has_attribute?(name)
  self.class.attribute_types.key?(name.to_s)
end
order_directions() click to toggle source

Returns a HashWithIndifferentAccess with a key for each defined {order}. Each key's associated value indicates that order's currently applied direction – :asc, :desc, or nil if the order is not applied. Note that only one order can be applied at a time, so, at most, one value in the Hash will be non-nil.

@example

class PostSearch < TalentScout::ModelSearch
  order :title
  order :created_at
end

PostSearch.new(order: "title").order_directions       # == { title: :asc, created_at: nil }
PostSearch.new(order: "title DESC").order_directions  # == { title: :desc, created_at: nil }
PostSearch.new(order: "created_at").order_directions  # == { title: nil, created_at: :asc }
PostSearch.new().order_directions                     # == { title: nil, created_at: nil }

@return [ActiveSupport::HashWithIndifferentAccess]

# File lib/talent_scout/model_search.rb, line 596
def order_directions
  @order_directions ||= begin
    order_after_cast = attribute_set.fetch("order", nil).try(&:value)
    self.class.order_type.definitions.transform_values{ nil }.
      merge!(self.class.order_type.obverse_mapping[order_after_cast] || {})
  end.freeze
end
results() click to toggle source

Applies the {default_scope}, search {criteria} with set or default attribute values, and the set or default {order} to the {model_class}. Returns an ActiveRecord::Relation, allowing further scopes, such as pagination, to be applied post-hoc.

@example

class PostSearch < TalentScout::ModelSearch
  criteria :title
  criteria :category
  criteria :published, :boolean, default: true

  order :created_at, default: :desc
  order :title
end

PostSearch.new(title: "FOO").results
  # == Post.where(title: "FOO", published: true).order("created_at DESC")
PostSearch.new(category: "math", order: :title).results
  # == Post.where(category: "math", published: true).order("title")

@return [ActiveRecord::Relation]

# File lib/talent_scout/model_search.rb, line 413
def results
  self.class.criteria_list.reduce(self.class.model_class.all) do |scope, crit|
    crit.apply(scope, attribute_set)
  end
end
to_query_params() click to toggle source

@!visibility private

# File lib/talent_scout/model_search.rb, line 605
def to_query_params
  attribute_set.values_before_type_cast.
    select{|key, value| attribute_set[key].changed? }
end
toggle_order(order_name, direction = nil) click to toggle source

Builds a new model search object with the specified order applied on top of the subject search object's criteria values. If the subject search object already has the specified order applied, the order's direction will be toggled from :asc to :desc or from :desc to :asc. Otherwise, the specified order will be applied with an :asc direction, overriding any previously applied order.

If direction is explicitly specified, that direction will be applied regardless of previously applied direction.

Does not modify the subject search object.

@example

class PostSearch < TalentScout::ModelSearch
  order :title
  order :created_at
end

search = PostSearch.new(order: :title)

search.toggle_order(:title).results       # == Post.order("title DESC")
search.toggle_order(:created_at).results  # == Post.order("created_at")
search.results                            # == Post.order("title")

@param order_name [String, Symbol] @param direction [:asc, :desc, nil] @return [TalentScout::ModelSearch] @raise [ArgumentError]

if +order_name+ is invalid
# File lib/talent_scout/model_search.rb, line 503
def toggle_order(order_name, direction = nil)
  definition = self.class.order_type.definitions[order_name]
  raise ArgumentError, "`#{order_name}` is not a valid order" unless definition
  direction ||= order_directions[order_name] == :asc ? :desc : :asc
  with(order: definition.choice_for_direction(direction))
end
type_for_attribute(name) click to toggle source

@!visibility private HACK Implemented by ActiveRecord but not ActiveModel. Expected by some third-party form builders, e.g. Simple Form.

# File lib/talent_scout/model_search.rb, line 620
def type_for_attribute(name)
  self.class.attribute_types[name.to_s]
end
with(criteria_values) click to toggle source

Builds a new model search object with criteria_values merged on top of the subject search object's criteria values.

Does not modify the subject search object.

@example

class PostSearch < TalentScout::ModelSearch
  criteria :title
  criteria :category
end

search = PostSearch.new(category: "math")

search.with(title: "FOO").results      # == Post.where(category: "math", title: "FOO")
search.with(category: "tech").results  # == Post.where(category: "tech")
search.results                         # == Post.where(category: "math")

@param criteria_values [Hash<String, Object>, Hash<Symbol, Object>] @return [TalentScout::ModelSearch] @raise [ActiveModel::UnknownAttributeError]

if one or more +criteria_values+ keys are invalid
# File lib/talent_scout/model_search.rb, line 440
def with(criteria_values)
  self.class.new(attributes.merge!(criteria_values.stringify_keys))
end
without(*criteria_names) click to toggle source

Builds a new model search object with the subject search object's criteria values, excluding values specified by criteria_names. Default criteria values will still be applied.

Does not modify the subject search object.

@example

class PostSearch < TalentScout::ModelSearch
  criteria :category
  criteria :published, :boolean, default: true
end

search = PostSearch.new(category: "math", published: false)

search.without(:category).results   # == Post.where(published: false)
search.without(:published).results  # == Post.where(category: "math", published: true)
search.results                      # == Post.where(category: "math", published: false)

@param criteria_names [Array<String>, Array<Symbol>] @return [TalentScout::ModelSearch] @raise [ActiveModel::UnknownAttributeError]

if one or more +criteria_names+ are invalid
# File lib/talent_scout/model_search.rb, line 466
def without(*criteria_names)
  criteria_names.map!(&:to_s)
  criteria_names.each do |name|
    raise ActiveModel::UnknownAttributeError.new(self, name) if !attribute_set.key?(name)
  end
  self.class.new(attributes.except!(*criteria_names))
end

Private Instance Methods

attribute_set() click to toggle source
# File lib/talent_scout/model_search.rb, line 636
def attribute_set
  @attributes # private instance variable from ActiveModel::Attributes ...YOLO!
end