Class: TalentScout::ModelSearch

Inherits:
Object
  • Object
show all
Extended by:
ActiveModel::Translation
Includes:
ActiveModel::Attributes, ActiveModel::Model, ActiveRecord::AttributeAssignment, ActiveRecord::AttributeMethods::BeforeTypeCast
Defined in:
lib/talent_scout/model_search.rb

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(params = {}) ⇒ ModelSearch

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.

Parameters:

  • params (Hash<String, Object>, Hash<Symbol, Object>, ActionController::Parameters) (defaults to: {})

Raises:

  • (ActiveModel::UnknownAttributeError)

    if params is a Hash, and it contains an unrecognized key



382
383
384
385
386
387
388
389
390
# 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

Class Method Details

.criteria(names, type = :string, **attribute_options) {|value| ... } ⇒ void .criteria(names, type = :string, **attribute_options) ⇒ void .criteria(names, choices: , **attribute_options) {|value| ... } ⇒ void .criteria(names, choices: , **attribute_options) ⇒ void

This method returns an undefined value.

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.

Examples:

Implicit block

class PostSearch < TalentScout::ModelSearch
  criteria :title
end

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

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%")

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")

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)

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")

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

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

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)

Overloads:

  • .criteria(names, type = :string, **attribute_options) {|value| ... } ⇒ void

    Parameters:

    • names (String, Symbol, Array<String>, Array<Symbol>)
    • type (Symbol, ActiveModel::Type) (defaults to: :string)
    • attribute_options (Hash)

    Options Hash (**attribute_options):

    • :default (Object)

    Yield Parameters:

    • value (Object)

    Yield Returns:

    • (ActiveRecord::Relation, nil)
  • .criteria(names, type = :string, **attribute_options) ⇒ void

    Parameters:

    • names (String, Symbol, Array<String>, Array<Symbol>)
    • type (Symbol, ActiveModel::Type) (defaults to: :string)
    • attribute_options (Hash)

    Options Hash (**attribute_options):

    • :default (Object)
  • .criteria(names, choices: , **attribute_options) {|value| ... } ⇒ void

    Parameters:

    • names (String, Symbol, Array<String>, Array<Symbol>)
    • choices (Array<String>, Array<Symbol>, Hash<String, Object>, Hash<Symbol, Object>) (defaults to: )
    • attribute_options (Hash)

    Options Hash (**attribute_options):

    • :default (Object)

    Yield Parameters:

    • value (Object)

    Yield Returns:

    • (ActiveRecord::Relation, nil)
  • .criteria(names, choices: , **attribute_options) ⇒ void

    Parameters:

    • names (String, Symbol, Array<String>, Array<Symbol>)
    • choices (Array<String>, Array<Symbol>, Hash<String, Object>, Hash<Symbol, Object>) (defaults to: )
    • attribute_options (Hash)

    Options Hash (**attribute_options):

    • :default (Object)

    Yield Returns:

    • (ActiveRecord::Relation, nil)

Raises:

  • (ArgumentError)

    if choices is specified and type is not :string



244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# 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) ⇒ void

This method returns an undefined value.

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.

Examples:

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

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

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

Yield Returns:

  • (ActiveRecord::Relation)


76
77
78
79
# 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_classClass

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.

Examples:

Default behavior

class PostSearch < TalentScout::ModelSearch
end

PostSearch.model_class  # == Post (class)

Override

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

EmployeeSearch.model_class  # == Person (class)

Returns:

  • (Class)

Raises:

  • (NameError)

    if the model class has not been set and the default class does not exist



32
33
34
35
# 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) ⇒ Class

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

Parameters:

  • model_class (Class)

Returns:

  • (Class)


41
42
43
# File 'lib/talent_scout/model_search.rb', line 41

def self.model_class=(model_class)
  @model_class = model_class
end

.order(name, columns = nil, default: false, **options) ⇒ void

This method returns an undefined value.

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.)

Examples:

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")

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")

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")

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")

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")

Parameters:

  • name (String, Symbol)
  • columns (Array<String>, Array<Symbol>, nil) (defaults to: nil)
  • options (Hash)

Options Hash (**options):

  • :default (Boolean, :asc, :desc) — default: false
  • :asc_suffix (String) — default: ".asc"
  • :desc_suffix (String) — default: ".desc"

See Also:



360
361
362
363
364
365
366
367
368
369
370
# 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

Instance Method Details

#each_choice(criteria_name) {|choice| ... } ⇒ void #each_choice(criteria_name) {|choice, chosen| ... } ⇒ void #each_choice(criteria_name) ⇒ Enumerator

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.

Examples:

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

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>"]

Overloads:

  • #each_choice(criteria_name) {|choice| ... } ⇒ void

    This method returns an undefined value.

    Parameters:

    • criteria_name (String, Symbol)

    Yield Parameters:

    • choice (String)
  • #each_choice(criteria_name) {|choice, chosen| ... } ⇒ void

    This method returns an undefined value.

    Parameters:

    • criteria_name (String, Symbol)

    Yield Parameters:

    • choice (String)
    • chosen (Boolean)
  • #each_choice(criteria_name) ⇒ Enumerator

    Parameters:

    • criteria_name (String, Symbol)

    Returns:

    • (Enumerator)

Raises:

  • (ArgumentError)

    if criteria_name is invalid, or the specified criteria does not define choices



563
564
565
566
567
568
569
570
571
572
573
574
575
576
# 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

#order_directionsActiveSupport::HashWithIndifferentAccess

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.

Examples:

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 }

Returns:

  • (ActiveSupport::HashWithIndifferentAccess)


596
597
598
599
600
601
602
# 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

#resultsActiveRecord::Relation

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.

Examples:

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")

Returns:

  • (ActiveRecord::Relation)


413
414
415
416
417
# 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

#toggle_order(order_name, direction = nil) ⇒ TalentScout::ModelSearch

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.

Examples:

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")

Parameters:

  • order_name (String, Symbol)
  • direction (:asc, :desc, nil) (defaults to: nil)

Returns:

Raises:

  • (ArgumentError)

    if order_name is invalid



503
504
505
506
507
508
# 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

#with(criteria_values) ⇒ TalentScout::ModelSearch

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.

Examples:

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")

Parameters:

  • criteria_values (Hash<String, Object>, Hash<Symbol, Object>)

Returns:

Raises:

  • (ActiveModel::UnknownAttributeError)

    if one or more criteria_values keys are invalid



440
441
442
# 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) ⇒ TalentScout::ModelSearch

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.

Examples:

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)

Parameters:

  • criteria_names (Array<String>, Array<Symbol>)

Returns:

Raises:

  • (ActiveModel::UnknownAttributeError)

    if one or more criteria_names are invalid



466
467
468
469
470
471
472
# 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