talent_scout 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +741 -0
- data/Rakefile +22 -0
- data/lib/generators/talent_scout/install/install_generator.rb +13 -0
- data/lib/generators/talent_scout/install/templates/config/locales/talent_scout.en.yml.tt +5 -0
- data/lib/generators/talent_scout/search/search_generator.rb +14 -0
- data/lib/generators/talent_scout/search/templates/search.rb.tt +4 -0
- data/lib/generators/test_unit/search_generator.rb +13 -0
- data/lib/generators/test_unit/templates/search_test.rb.tt +4 -0
- data/lib/talent_scout/choice_type.rb +33 -0
- data/lib/talent_scout/controller.rb +61 -0
- data/lib/talent_scout/criteria.rb +40 -0
- data/lib/talent_scout/helper.rb +82 -0
- data/lib/talent_scout/model_name.rb +18 -0
- data/lib/talent_scout/model_search.rb +611 -0
- data/lib/talent_scout/order_definition.rb +37 -0
- data/lib/talent_scout/order_type.rb +32 -0
- data/lib/talent_scout/railtie.rb +14 -0
- data/lib/talent_scout/version.rb +3 -0
- data/lib/talent_scout/void_type.rb +16 -0
- data/lib/talent_scout.rb +17 -0
- metadata +140 -0
data/Rakefile
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
begin
|
2
|
+
require 'bundler/setup'
|
3
|
+
rescue LoadError
|
4
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
5
|
+
end
|
6
|
+
|
7
|
+
require 'yard'
|
8
|
+
|
9
|
+
YARD::Rake::YardocTask.new(:doc) do |t|
|
10
|
+
end
|
11
|
+
|
12
|
+
require 'bundler/gem_tasks'
|
13
|
+
|
14
|
+
require 'rake/testtask'
|
15
|
+
|
16
|
+
Rake::TestTask.new(:test) do |t|
|
17
|
+
t.libs << 'test'
|
18
|
+
t.test_files = FileList['test/**/*_test.rb'].exclude('test/tmp/**/*')
|
19
|
+
t.verbose = false
|
20
|
+
end
|
21
|
+
|
22
|
+
task default: :test
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module TalentScout
|
2
|
+
# @!visibility private
|
3
|
+
module Generators
|
4
|
+
class InstallGenerator < ::Rails::Generators::Base
|
5
|
+
source_root File.join(__dir__, "templates")
|
6
|
+
|
7
|
+
def copy_locales
|
8
|
+
template "config/locales/talent_scout.en.yml",
|
9
|
+
{ param_key: TalentScout::PARAM_KEY }
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module TalentScout
|
2
|
+
# @!visibility private
|
3
|
+
module Generators
|
4
|
+
class SearchGenerator < ::Rails::Generators::NamedBase
|
5
|
+
source_root File.join(__dir__, "templates")
|
6
|
+
hook_for :test_framework
|
7
|
+
|
8
|
+
def generate_search
|
9
|
+
template "search.rb",
|
10
|
+
File.join("app/searches", class_path, "#{file_name}_search.rb")
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# @!visibility private
|
2
|
+
module TestUnit
|
3
|
+
module Generators
|
4
|
+
class SearchGenerator < ::Rails::Generators::NamedBase
|
5
|
+
source_root File.join(__dir__, "templates")
|
6
|
+
|
7
|
+
def generate_test
|
8
|
+
template "search_test.rb",
|
9
|
+
File.join("test/searches", class_path, "#{file_name}_search_test.rb")
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module TalentScout
|
2
|
+
# @!visibility private
|
3
|
+
class ChoiceType < ActiveModel::Type::Value
|
4
|
+
|
5
|
+
attr_reader :mapping
|
6
|
+
|
7
|
+
def initialize(mapping)
|
8
|
+
@mapping = if mapping.is_a?(Hash)
|
9
|
+
unless mapping.all?{|key, value| key.is_a?(String) || key.is_a?(Symbol) }
|
10
|
+
raise ArgumentError, "Only String and Symbol keys are supported"
|
11
|
+
end
|
12
|
+
mapping.stringify_keys
|
13
|
+
else
|
14
|
+
mapping.index_by(&:to_s)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize_copy(orig)
|
19
|
+
super
|
20
|
+
@mapping = @mapping.dup
|
21
|
+
end
|
22
|
+
|
23
|
+
def cast(value)
|
24
|
+
key = value.to_s if value.is_a?(String) || value.is_a?(Symbol)
|
25
|
+
if @mapping.key?(key)
|
26
|
+
super(@mapping[key])
|
27
|
+
elsif @mapping.value?(value)
|
28
|
+
super(value)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module TalentScout
|
2
|
+
module Controller
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
module ClassMethods
|
6
|
+
# Returns the controller model search class. Defaults to a class
|
7
|
+
# corresponding to the singular-form of the controller name. The
|
8
|
+
# model search class can also be set with {model_search_class=}.
|
9
|
+
# If the model search class has not been set, and the default
|
10
|
+
# class does not exist, a +NameError+ will be raised.
|
11
|
+
#
|
12
|
+
# @example
|
13
|
+
# class PostsController < ApplicationController
|
14
|
+
# end
|
15
|
+
#
|
16
|
+
# PostsController.model_search_class # == PostSearch (class)
|
17
|
+
#
|
18
|
+
# @return [Class<TalentScout::ModelSearch>]
|
19
|
+
# @raise [NameError]
|
20
|
+
# if the model search class has not been set and the default
|
21
|
+
# class does not exist
|
22
|
+
def model_search_class
|
23
|
+
@model_search_class ||= "#{controller_path.classify}Search".constantize
|
24
|
+
end
|
25
|
+
|
26
|
+
# Sets the controller model search class. See {model_search_class}.
|
27
|
+
#
|
28
|
+
# @param klass [Class<TalentScout::ModelSearch>]
|
29
|
+
# @return [Class<TalentScout::ModelSearch>]
|
30
|
+
def model_search_class=(klass)
|
31
|
+
@model_search_class = klass
|
32
|
+
end
|
33
|
+
|
34
|
+
# Similar to {model_search_class}, but returns nil instead of
|
35
|
+
# raising an error when the value has not been set (via
|
36
|
+
# {model_search_class=}) and the default class does not exist.
|
37
|
+
#
|
38
|
+
# @return [Class<TalentScout::ModelSearch>, nil]
|
39
|
+
def model_search_class?
|
40
|
+
return @model_search_class if defined?(@model_search_class)
|
41
|
+
begin
|
42
|
+
model_search_class
|
43
|
+
rescue NameError
|
44
|
+
@model_search_class = nil
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Instantiates {ClassMethods#model_search_class} using the current
|
50
|
+
# request's query params. If that class does not exist, a
|
51
|
+
# +NameError+ will be raised.
|
52
|
+
#
|
53
|
+
# @return [TalentScout::ModelSearch]
|
54
|
+
# @raise [NameError]
|
55
|
+
# if the model search class does not exist
|
56
|
+
def model_search()
|
57
|
+
param_key = self.class.model_search_class.model_name.param_key
|
58
|
+
self.class.model_search_class.new(params[param_key])
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module TalentScout
|
2
|
+
# @!visibility private
|
3
|
+
class Criteria
|
4
|
+
|
5
|
+
attr_reader :names, :allow_nil, :block
|
6
|
+
|
7
|
+
def initialize(names, allow_nil, &block)
|
8
|
+
@names = Array(names).map(&:to_s)
|
9
|
+
@allow_nil = allow_nil
|
10
|
+
@block = block
|
11
|
+
end
|
12
|
+
|
13
|
+
def apply(scope, attribute_set)
|
14
|
+
if applicable?(attribute_set)
|
15
|
+
if block
|
16
|
+
block_args = names.map{|name| attribute_set[name].value }
|
17
|
+
if block.arity == -1 # block from Symbol#to_proc
|
18
|
+
scope.instance_exec(scope, *block_args, &block)
|
19
|
+
else
|
20
|
+
scope.instance_exec(*block_args, &block)
|
21
|
+
end || scope
|
22
|
+
else
|
23
|
+
where_args = names.reduce({}){|h, name| h[name] = attribute_set[name].value; h }
|
24
|
+
scope.where(where_args)
|
25
|
+
end
|
26
|
+
else
|
27
|
+
scope
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def applicable?(attribute_set)
|
32
|
+
names.all? do |name|
|
33
|
+
attribute = attribute_set[name]
|
34
|
+
attribute.came_from_user? &&
|
35
|
+
(!attribute.value.nil? || (allow_nil && attribute.value_before_type_cast.nil?))
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module TalentScout
|
2
|
+
module Helper
|
3
|
+
|
4
|
+
# Renders an anchor element that links to a specified search. The
|
5
|
+
# search is specified in the form of a {TalentScout::ModelSearch}
|
6
|
+
# search object, which is converted to URL query params. By
|
7
|
+
# default, the link will point to the current controller and current
|
8
|
+
# action, but this can be overridden by passing a +search_options+
|
9
|
+
# Hash in place of the search object (see method overloads).
|
10
|
+
#
|
11
|
+
# @overload link_to_search(name, search, html_options = nil)
|
12
|
+
# @param name [String]
|
13
|
+
# link text
|
14
|
+
# @param search [TalentScout::ModelSearch]
|
15
|
+
# search object
|
16
|
+
# @param html_options [Hash, nil]
|
17
|
+
# HTML options (see +ActionView::Helpers::UrlHelper#link_to+)
|
18
|
+
#
|
19
|
+
# @overload link_to_search(search, html_options = nil, &block)
|
20
|
+
# @param search [TalentScout::ModelSearch]
|
21
|
+
# search object
|
22
|
+
# @param html_options [Hash, nil]
|
23
|
+
# HTML options (see +ActionView::Helpers::UrlHelper#link_to+)
|
24
|
+
# @yieldreturn [String]
|
25
|
+
# link text
|
26
|
+
#
|
27
|
+
# @overload link_to_search(name, search_options, html_options = nil)
|
28
|
+
# @param name [String]
|
29
|
+
# link text
|
30
|
+
# @param search_options [Hash]
|
31
|
+
# search options
|
32
|
+
# @option search_options :search [TalentScout::ModelSearch]
|
33
|
+
# search object
|
34
|
+
# @option search_options :controller [String, nil]
|
35
|
+
# controller to link to (defaults to current controller)
|
36
|
+
# @option search_options :action [String, nil]
|
37
|
+
# controller action to link to (defaults to current action)
|
38
|
+
# @param html_options [Hash, nil]
|
39
|
+
# HTML options (see +ActionView::Helpers::UrlHelper#link_to+)
|
40
|
+
#
|
41
|
+
# @overload link_to_search(search_options, html_options = nil, &block)
|
42
|
+
# @param search_options [Hash]
|
43
|
+
# search options
|
44
|
+
# @option search_options :search [TalentScout::ModelSearch]
|
45
|
+
# search object
|
46
|
+
# @option search_options :controller [String, nil]
|
47
|
+
# controller to link to (defaults to current controller)
|
48
|
+
# @option search_options :action [String, nil]
|
49
|
+
# controller action to link to (defaults to current action)
|
50
|
+
# @param html_options [Hash, nil]
|
51
|
+
# HTML options (see +ActionView::Helpers::UrlHelper#link_to+)
|
52
|
+
# @yieldreturn [String]
|
53
|
+
# link text
|
54
|
+
#
|
55
|
+
# @return [String]
|
56
|
+
# @raise [ArgumentError]
|
57
|
+
# if +search+ or <code>search_options[:search]</code> is nil
|
58
|
+
def link_to_search(name, search = nil, html_options = nil, &block)
|
59
|
+
name, search, html_options = nil, name, search if block_given?
|
60
|
+
|
61
|
+
if search.is_a?(Hash)
|
62
|
+
url_options = search.dup
|
63
|
+
search = url_options.delete(:search)
|
64
|
+
else
|
65
|
+
url_options = {}
|
66
|
+
end
|
67
|
+
|
68
|
+
raise ArgumentError, "`search` cannot be nil" if search.nil?
|
69
|
+
|
70
|
+
url_options[:controller] ||= controller_path
|
71
|
+
url_options[:action] ||= action_name
|
72
|
+
url_options[search.model_name.param_key] = search.to_query_params
|
73
|
+
|
74
|
+
if block_given?
|
75
|
+
link_to(url_options, html_options, &block)
|
76
|
+
else
|
77
|
+
link_to(name, url_options, html_options)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module TalentScout
|
2
|
+
# @!visibility private
|
3
|
+
class ModelName < ActiveModel::Name
|
4
|
+
|
5
|
+
def param_key
|
6
|
+
TalentScout::PARAM_KEY
|
7
|
+
end
|
8
|
+
|
9
|
+
def route_key
|
10
|
+
@klass.model_class.model_name.route_key
|
11
|
+
end
|
12
|
+
|
13
|
+
def singular_route_key
|
14
|
+
@klass.model_class.model_name.singular_route_key
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,611 @@
|
|
1
|
+
module TalentScout
|
2
|
+
class ModelSearch
|
3
|
+
include ActiveModel::Model
|
4
|
+
include ActiveModel::Attributes
|
5
|
+
include ActiveRecord::AttributeAssignment
|
6
|
+
include ActiveRecord::AttributeMethods::BeforeTypeCast
|
7
|
+
extend ActiveModel::Translation
|
8
|
+
|
9
|
+
# Returns the model class that the search targets. Defaults to a
|
10
|
+
# class with same name name as the search class, minus the "Search"
|
11
|
+
# suffix. The model class can also be set with {model_class=}.
|
12
|
+
# If the model class has not been set, and the default class does
|
13
|
+
# not exist, a +NameError+ will be raised.
|
14
|
+
#
|
15
|
+
# @example Default behavior
|
16
|
+
# class PostSearch < TalentScout::ModelSearch
|
17
|
+
# end
|
18
|
+
#
|
19
|
+
# PostSearch.model_class # == Post (class)
|
20
|
+
#
|
21
|
+
# @example Override
|
22
|
+
# class EmployeeSearch < TalentScout::ModelSearch
|
23
|
+
# self.model_class = Person
|
24
|
+
# end
|
25
|
+
#
|
26
|
+
# EmployeeSearch.model_class # == Person (class)
|
27
|
+
#
|
28
|
+
# @return [Class]
|
29
|
+
# @raise [NameError]
|
30
|
+
# if the model class has not been set and the default class does
|
31
|
+
# not exist
|
32
|
+
def self.model_class
|
33
|
+
@model_class ||= self.superclass == ModelSearch ?
|
34
|
+
self.name.chomp("Search").constantize : self.superclass.model_class
|
35
|
+
end
|
36
|
+
|
37
|
+
# Sets the model class that the search targets. See {model_class}.
|
38
|
+
#
|
39
|
+
# @param model_class [Class]
|
40
|
+
# @return [Class]
|
41
|
+
def self.model_class=(model_class)
|
42
|
+
@model_class = model_class
|
43
|
+
end
|
44
|
+
|
45
|
+
# @!visibility private
|
46
|
+
def self.model_name
|
47
|
+
@model_name ||= ModelName.new(self)
|
48
|
+
end
|
49
|
+
|
50
|
+
# Sets the default scope of the search. Like ActiveRecord's
|
51
|
+
# +default_scope+, the scope here is specified as a block which is
|
52
|
+
# evaluated in the context of the {model_class}. Also like
|
53
|
+
# ActiveRecord, multiple calls to this method will be merged
|
54
|
+
# together.
|
55
|
+
#
|
56
|
+
# @example
|
57
|
+
# class PostSearch < TalentScout::ModelSearch
|
58
|
+
# default_scope { where(published: true) }
|
59
|
+
# end
|
60
|
+
#
|
61
|
+
# PostSearch.new.results # == Post.where(published: true)
|
62
|
+
#
|
63
|
+
# @example Using an existing scope
|
64
|
+
# class Post < ActiveRecord::Base
|
65
|
+
# scope :published, ->{ where(published: true) }
|
66
|
+
# end
|
67
|
+
#
|
68
|
+
# class PostSearch < TalentScout::ModelSearch
|
69
|
+
# default_scope(&:published)
|
70
|
+
# end
|
71
|
+
#
|
72
|
+
# PostSearch.new.results # == Post.published
|
73
|
+
#
|
74
|
+
# @yieldreturn [ActiveRecord::Relation]
|
75
|
+
# @return [void]
|
76
|
+
def self.default_scope(&block)
|
77
|
+
i = criteria_list.index{|crit| !crit.names.empty? } || -1
|
78
|
+
criteria_list.insert(i, Criteria.new([], true, &block))
|
79
|
+
end
|
80
|
+
|
81
|
+
# Defines criteria to incorporate into the search. Each criteria
|
82
|
+
# corresponds to an attribute on the search object that can be used
|
83
|
+
# when building a search form.
|
84
|
+
#
|
85
|
+
# Each attribute has a type, just as Active Model attributes do, and
|
86
|
+
# values passed into the search object are typecasted before
|
87
|
+
# criteria are evaluated. Supported types are the same as Active
|
88
|
+
# Model (e.g. +:string+, +:boolean+, +:integer+, etc), with the
|
89
|
+
# addition of a +:void+ type. A +:void+ type is just like a
|
90
|
+
# +:boolean+ type, except that the criteria is not evaluated when
|
91
|
+
# the type-casted value is falsey.
|
92
|
+
#
|
93
|
+
# Alternatively, instead of a type, an array or hash of +choices+
|
94
|
+
# can be specified, and the criteria will be evaluated only if the
|
95
|
+
# passed-in value matches one of the choices.
|
96
|
+
#
|
97
|
+
# Active Model +attribute_options+ can also be specified, most
|
98
|
+
# notably +:default+ to provide the criteria a default value to
|
99
|
+
# operate on.
|
100
|
+
#
|
101
|
+
# Each criteria can specify a block which recieves the corresponding
|
102
|
+
# type-casted value as an argument. If the corresponding value is
|
103
|
+
# not set on the search object (and no default value is defined),
|
104
|
+
# the criteria will not be evaluated. Like an Active Record
|
105
|
+
# +scope+ block, a criteria block is evaluated in the context of an
|
106
|
+
# +ActiveRecord::Relation+ and should return an
|
107
|
+
# +ActiveRecord::Relation+. A criteria block may also return nil,
|
108
|
+
# in which case the criteria will be skipped. If no criteria block
|
109
|
+
# is specified, the criteria will be evaluated as a +where+ clause
|
110
|
+
# using the criteria name and type-casted value.
|
111
|
+
#
|
112
|
+
# As a convenient shorthand, Active Record scopes which have been
|
113
|
+
# defined on the {model_class} can be used directly as criteria
|
114
|
+
# blocks by passing the scope's name as a symbol-to-proc in place of
|
115
|
+
# the criteria block.
|
116
|
+
#
|
117
|
+
#
|
118
|
+
# @example Implicit block
|
119
|
+
# class PostSearch < TalentScout::ModelSearch
|
120
|
+
# criteria :title
|
121
|
+
# end
|
122
|
+
#
|
123
|
+
# PostSearch.new(title: "FOO").results # == Post.where(title: "FOO")
|
124
|
+
#
|
125
|
+
#
|
126
|
+
# @example Explicit block
|
127
|
+
# class PostSearch < TalentScout::ModelSearch
|
128
|
+
# criteria :title do |string|
|
129
|
+
# where("title LIKE ?", "%#{string}%")
|
130
|
+
# end
|
131
|
+
# end
|
132
|
+
#
|
133
|
+
# PostSearch.new(title: "FOO").results # == Post.where("title LIKE ?", "%FOO%")
|
134
|
+
#
|
135
|
+
#
|
136
|
+
# @example Using an existing Active Record scope
|
137
|
+
# class Post < ActiveRecord::Base
|
138
|
+
# scope :title_includes, ->(string){ where("title LIKE ?", "%#{string}%") }
|
139
|
+
# end
|
140
|
+
#
|
141
|
+
# class PostSearch < TalentScout::ModelSearch
|
142
|
+
# criteria :title, &:title_includes
|
143
|
+
# end
|
144
|
+
#
|
145
|
+
# PostSearch.new(title: "FOO").results # == Post.title_includes("FOO")
|
146
|
+
#
|
147
|
+
#
|
148
|
+
# @example Specifying a type
|
149
|
+
# class PostSearch < TalentScout::ModelSearch
|
150
|
+
# criteria :created_on, :date do |date|
|
151
|
+
# where(created_at: date.beginning_of_day..date.end_of_day)
|
152
|
+
# end
|
153
|
+
# end
|
154
|
+
#
|
155
|
+
# PostSearch.new(created_on: "Dec 31, 1999").results
|
156
|
+
# # == Post.where(created_at: Date.new(1999, 12, 31).beginning_of_day..
|
157
|
+
# # Date.new(1999, 12, 31).end_of_day)
|
158
|
+
#
|
159
|
+
#
|
160
|
+
# @example Using the void type
|
161
|
+
# class PostSearch < TalentScout::ModelSearch
|
162
|
+
# criteria :only_edited, :void do
|
163
|
+
# where("modified_at > created_at")
|
164
|
+
# end
|
165
|
+
# end
|
166
|
+
#
|
167
|
+
# PostSearch.new(only_edited: false).results # == Post.all
|
168
|
+
# PostSearch.new(only_edited: "0").results # == Post.all
|
169
|
+
# PostSearch.new(only_edited: "").results # == Post.all
|
170
|
+
# PostSearch.new(only_edited: true).results # == Post.where("modified_at > created_at")
|
171
|
+
# PostSearch.new(only_edited: "1").results # == Post.where("modified_at > created_at")
|
172
|
+
#
|
173
|
+
#
|
174
|
+
# @example Specifying choices (array)
|
175
|
+
# class PostSearch < TalentScout::ModelSearch
|
176
|
+
# criteria :category, choices: %w[science tech engineering math]
|
177
|
+
# end
|
178
|
+
#
|
179
|
+
# PostSearch.new(category: "math").results # == Post.where(category: "math")
|
180
|
+
# PostSearch.new(category: "BLAH").results # == Post.all
|
181
|
+
#
|
182
|
+
#
|
183
|
+
# @example Specifying choices (hash)
|
184
|
+
# class PostSearch < TalentScout::ModelSearch
|
185
|
+
# criteria :within, choices: {
|
186
|
+
# "Last 24 hours" => 24.hours,
|
187
|
+
# "Past Week" => 1.week,
|
188
|
+
# "Past Month" => 1.month,
|
189
|
+
# "Past Year" => 1.year,
|
190
|
+
# } do |duration|
|
191
|
+
# where("created_at >= ?", duration.ago)
|
192
|
+
# end
|
193
|
+
# end
|
194
|
+
#
|
195
|
+
# PostSearch.new(within: "Last 24 hours").results # == Post.where("created_at >= ?", 24.hours.ago)
|
196
|
+
# PostSearch.new(within: 24.hours).results # == Post.where("created_at >= ?", 24.hours.ago)
|
197
|
+
# PostSearch.new(within: 23.hours).results # == Post.all
|
198
|
+
#
|
199
|
+
#
|
200
|
+
#
|
201
|
+
# @example Specifying a default value
|
202
|
+
# class PostSearch < TalentScout::ModelSearch
|
203
|
+
# criteria :within_days, :integer, default: 7 do |num|
|
204
|
+
# where("created_at >= ?", num.days.ago)
|
205
|
+
# end
|
206
|
+
# end
|
207
|
+
#
|
208
|
+
# PostSearch.new().results # == Post.where("created_at >= ?", 7.days.ago)
|
209
|
+
# PostSearch.new(within_days: 2).results # == Post.where("created_at >= ?", 2.days.ago)
|
210
|
+
#
|
211
|
+
#
|
212
|
+
# @param names [String, Symbol, Array<String>, Array<Symbol>]
|
213
|
+
# @param type [Symbol, ActiveModel::Type]
|
214
|
+
# @param choices [Array<String>, Array<Symbol>, Hash<String, Object>, Hash<Symbol, Object>]
|
215
|
+
# @param attribute_options [Hash]
|
216
|
+
# @option attribute_options :default [Object]
|
217
|
+
# @yieldreturn [ActiveRecord::Relation, nil]
|
218
|
+
# @return [void]
|
219
|
+
# @raise [ArgumentError]
|
220
|
+
# if +choices+ are specified and +type+ is other than +:string+
|
221
|
+
def self.criteria(names, type = :string, choices: nil, **attribute_options, &block)
|
222
|
+
if choices
|
223
|
+
if type != :string
|
224
|
+
raise ArgumentError, "Option :choices cannot be used with type #{type}"
|
225
|
+
end
|
226
|
+
type = ChoiceType.new(choices)
|
227
|
+
elsif type == :void
|
228
|
+
type = VoidType.new
|
229
|
+
elsif type.is_a?(Symbol)
|
230
|
+
# HACK force ActiveRecord::Type.lookup because datetime types
|
231
|
+
# from ActiveModel::Type.lookup don't support multi-parameter
|
232
|
+
# attribute assignment
|
233
|
+
type = ActiveRecord::Type.lookup(type)
|
234
|
+
end
|
235
|
+
|
236
|
+
crit = Criteria.new(names, !type.is_a?(VoidType), &block)
|
237
|
+
criteria_list << crit
|
238
|
+
|
239
|
+
crit.names.each do |name|
|
240
|
+
attribute name, type, attribute_options
|
241
|
+
|
242
|
+
# HACK FormBuilder#select uses normal attribute readers instead
|
243
|
+
# of `*_before_type_cast` attribute readers. This breaks value
|
244
|
+
# auto-selection for types where the two are appreciably
|
245
|
+
# different, e.g. ChoiceType with hash mapping. Work around by
|
246
|
+
# aliasing relevant attribute readers to `*_before_type_cast`.
|
247
|
+
if type.is_a?(ChoiceType)
|
248
|
+
alias_method name, "#{name}_before_type_cast"
|
249
|
+
end
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
# Defines an order that the search can apply to its results. Only
|
254
|
+
# one order can be applied at a time, but an order can be defined
|
255
|
+
# over multiple columns. If no columns are specified, the order's
|
256
|
+
# +name+ is taken as its column.
|
257
|
+
#
|
258
|
+
# Each order can be applied in an ascending or descending direction
|
259
|
+
# by appending a corresponding suffix to the order value. By
|
260
|
+
# default, these suffixes are +".asc"+ and +".desc"+, but they can
|
261
|
+
# be overridden in the order definition using the +:asc_suffix+ and
|
262
|
+
# +:desc_suffix+ options, respectively.
|
263
|
+
#
|
264
|
+
# Order direction affects all columns of an order defintion, unless
|
265
|
+
# a column explicitly specifies +"ASC"+ or +"DESC"+, in which case
|
266
|
+
# that column will stay fixed in its specified direction.
|
267
|
+
#
|
268
|
+
# To apply an order to the search results by default, use the
|
269
|
+
# +:default+ option in the order definition. (Note that only one
|
270
|
+
# order can be designated as the default order.)
|
271
|
+
#
|
272
|
+
# See also {toggle_order}.
|
273
|
+
#
|
274
|
+
#
|
275
|
+
# @example Single-column order
|
276
|
+
# class PostSearch < TalentScout::ModelSearch
|
277
|
+
# order :title
|
278
|
+
# end
|
279
|
+
#
|
280
|
+
# PostSearch.new(order: :title).results # == Post.order("title")
|
281
|
+
# PostSearch.new(order: "title.asc").results # == Post.order("title")
|
282
|
+
# PostSearch.new(order: "title.desc").results # == Post.order("title DESC")
|
283
|
+
#
|
284
|
+
#
|
285
|
+
# @example Multi-column order
|
286
|
+
# class PostSearch < TalentScout::ModelSearch
|
287
|
+
# order :category, [:category, :title]
|
288
|
+
# end
|
289
|
+
#
|
290
|
+
# PostSearch.new(order: :category).results # == Post.order("category, title")
|
291
|
+
# PostSearch.new(order: "category.asc").results # == Post.order("category, title")
|
292
|
+
# PostSearch.new(order: "category.desc").results # == Post.order("category DESC, title DESC")
|
293
|
+
#
|
294
|
+
#
|
295
|
+
# @example Multi-column order, fixed directions
|
296
|
+
# class PostSearch < TalentScout::ModelSearch
|
297
|
+
# order :category, ["category", "title ASC", "created_at DESC"]
|
298
|
+
# end
|
299
|
+
#
|
300
|
+
# PostSearch.new(order: :category).results
|
301
|
+
# # == Post.order("category, title ASC, created_at DESC")
|
302
|
+
# PostSearch.new(order: "category.asc").results
|
303
|
+
# # == Post.order("category, title ASC, created_at DESC")
|
304
|
+
# PostSearch.new(order: "category.desc").results
|
305
|
+
# # == Post.order("category DESC, title ASC, created_at DESC")
|
306
|
+
#
|
307
|
+
#
|
308
|
+
# @example Specifying direction suffixes
|
309
|
+
# class PostSearch < TalentScout::ModelSearch
|
310
|
+
# order "Title", [:title], asc_suffix: " (A-Z)", desc_suffix: " (Z-A)"
|
311
|
+
# end
|
312
|
+
#
|
313
|
+
# PostSearch.new(order: "Title").results # == Post.order("title")
|
314
|
+
# PostSearch.new(order: "Title (A-Z)").results # == Post.order("title")
|
315
|
+
# PostSearch.new(order: "Title (Z-A)").results # == Post.order("title DESC")
|
316
|
+
#
|
317
|
+
#
|
318
|
+
# @example Default order
|
319
|
+
# class PostSearch < TalentScout::ModelSearch
|
320
|
+
# order :created_at, default: :desc
|
321
|
+
# order :title
|
322
|
+
# end
|
323
|
+
#
|
324
|
+
# PostSearch.new().results # == Post.order("created_at DESC")
|
325
|
+
# PostSearch.new(order: :created_at).results # == Post.order("created_at")
|
326
|
+
# PostSearch.new(order: "created_at.asc").results # == Post.order("created_at")
|
327
|
+
# PostSearch.new(order: :title).results # == Post.order("title")
|
328
|
+
#
|
329
|
+
#
|
330
|
+
# @param name [String, Symbol]
|
331
|
+
# @param columns [Array<String>, Array<Symbol>, nil]
|
332
|
+
# @param options [Hash]
|
333
|
+
# @option options :default [Boolean, :asc, :desc] (false)
|
334
|
+
# @option options :asc_suffix [String] (".asc")
|
335
|
+
# @option options :desc_suffix [String] (".desc")
|
336
|
+
# @return [void]
|
337
|
+
def self.order(name, columns = nil, default: false, **options)
|
338
|
+
definition = OrderDefinition.new(name, columns, options)
|
339
|
+
|
340
|
+
if !attribute_types.fetch("order", nil).equal?(order_type) || default
|
341
|
+
criteria_options = default ? { default: definition.choice_for_direction(default) } : {}
|
342
|
+
criteria_list.reject!{|crit| crit.names == ["order"] }
|
343
|
+
criteria "order", order_type, criteria_options, &:order
|
344
|
+
end
|
345
|
+
|
346
|
+
order_type.add_definition(definition)
|
347
|
+
end
|
348
|
+
|
349
|
+
# Initializes a +ModelSearch+ instance. Assigns values in +params+
|
350
|
+
# to appropriate criteria attributes.
|
351
|
+
#
|
352
|
+
# If +params+ is a +ActionController::Parameters+, blank values are
|
353
|
+
# ignored. This behavior prevents empty search form fields from
|
354
|
+
# affecting search results.
|
355
|
+
#
|
356
|
+
# @param params [Hash<String, Object>, Hash<Symbol, Object>, ActionController::Parameters]
|
357
|
+
def initialize(params = {})
|
358
|
+
if params.is_a?(ActionController::Parameters)
|
359
|
+
params = params.permit(self.class.attribute_types.keys).reject!{|key, value| value.blank? }
|
360
|
+
end
|
361
|
+
super(params)
|
362
|
+
end
|
363
|
+
|
364
|
+
# Applies search {criteria} with set or default attribute values,
|
365
|
+
# and the set or default {order} on top of the {default_scope}.
|
366
|
+
# Returns an +ActiveRecord::Relation+, allowing further scopes, such
|
367
|
+
# as pagination, to be applied post-hoc.
|
368
|
+
#
|
369
|
+
# @example
|
370
|
+
# class PostSearch < TalentScout::ModelSearch
|
371
|
+
# criteria :title
|
372
|
+
# criteria :category
|
373
|
+
# criteria :published, :boolean, default: true
|
374
|
+
#
|
375
|
+
# order :created_at, default: :desc
|
376
|
+
# order :title
|
377
|
+
# end
|
378
|
+
#
|
379
|
+
# PostSearch.new(title: "FOO").results
|
380
|
+
# # == Post.where(title: "FOO", published: true).order("created_at DESC")
|
381
|
+
# PostSearch.new(category: "math", order: :title).results
|
382
|
+
# # == Post.where(category: "math", published: true).order("title")
|
383
|
+
#
|
384
|
+
# @return [ActiveRecord::Relation]
|
385
|
+
def results
|
386
|
+
self.class.criteria_list.reduce(self.class.model_class) do |scope, crit|
|
387
|
+
crit.apply(scope, attribute_set)
|
388
|
+
end
|
389
|
+
end
|
390
|
+
|
391
|
+
# Builds a new model search object with +criteria_values+ merged on
|
392
|
+
# top of the subject search object's criteria values. Does not
|
393
|
+
# modify the subject search object.
|
394
|
+
#
|
395
|
+
# @example
|
396
|
+
# class PostSearch < TalentScout::ModelSearch
|
397
|
+
# criteria :title
|
398
|
+
# criteria :category
|
399
|
+
# end
|
400
|
+
#
|
401
|
+
# search = PostSearch.new(category: "math")
|
402
|
+
#
|
403
|
+
# search.with(title: "FOO").results # == Post.where(category: "math", title: "FOO")
|
404
|
+
# search.with(category: "tech").results # == Post.where(category: "tech")
|
405
|
+
# search.results # == Post.where(category: "math")
|
406
|
+
#
|
407
|
+
# @param criteria_values [Hash<String, Object>, Hash<Symbol, Object>]
|
408
|
+
# @return [TalentScout::ModelSearch]
|
409
|
+
# @raise [ActiveModel::UnknownAttributeError]
|
410
|
+
# if one or more +criteria_values+ keys are invalid
|
411
|
+
def with(criteria_values)
|
412
|
+
self.class.new(attributes.merge!(criteria_values.stringify_keys))
|
413
|
+
end
|
414
|
+
|
415
|
+
# Builds a new model search object with the subject search object's
|
416
|
+
# criteria values, excluding values specified by +criteria_names+.
|
417
|
+
# Default criteria values will still be applied. Does not modify
|
418
|
+
# the subject search object.
|
419
|
+
#
|
420
|
+
# @example
|
421
|
+
# class PostSearch < TalentScout::ModelSearch
|
422
|
+
# criteria :category
|
423
|
+
# criteria :published, :boolean, default: true
|
424
|
+
# end
|
425
|
+
#
|
426
|
+
# search = PostSearch.new(category: "math", published: false)
|
427
|
+
#
|
428
|
+
# search.without(:category).results # == Post.where(published: false)
|
429
|
+
# search.without(:published).results # == Post.where(category: "math", published: true)
|
430
|
+
# search.results # == Post.where(category: "math", published: false)
|
431
|
+
#
|
432
|
+
# @param criteria_names [Array<String>, Array<Symbol>]
|
433
|
+
# @return [TalentScout::ModelSearch]
|
434
|
+
# @raise [ActiveModel::UnknownAttributeError]
|
435
|
+
# if one or more +criteria_names+ are invalid
|
436
|
+
def without(*criteria_names)
|
437
|
+
criteria_names.map!(&:to_s)
|
438
|
+
criteria_names.each do |name|
|
439
|
+
raise ActiveModel::UnknownAttributeError.new(self, name) if !attribute_set.key?(name)
|
440
|
+
end
|
441
|
+
self.class.new(attributes.except!(*criteria_names))
|
442
|
+
end
|
443
|
+
|
444
|
+
# Builds a new model search object with the specified order applied
|
445
|
+
# on top of the subject search object's criteria values. If the
|
446
|
+
# subject search object already has the specified order applied, the
|
447
|
+
# order's direction will be toggled from +:asc+ to +:desc+ or from
|
448
|
+
# +:desc+ to +:asc+. Otherwise, the specified order will be applied
|
449
|
+
# with an +:asc+ direction, overriding any previously applied order.
|
450
|
+
#
|
451
|
+
# If +direction+ is explicitly specified, that direction will be
|
452
|
+
# applied regardless of previously applied direction.
|
453
|
+
#
|
454
|
+
# Does not modify the subject search object.
|
455
|
+
#
|
456
|
+
# @example
|
457
|
+
# class PostSearch < TalentScout::ModelSearch
|
458
|
+
# order :title
|
459
|
+
# order :created_at
|
460
|
+
# end
|
461
|
+
#
|
462
|
+
# search = PostSearch.new(order: :title)
|
463
|
+
#
|
464
|
+
# search.toggle_order(:title).results # == Post.order("title DESC")
|
465
|
+
# search.toggle_order(:created_at).results # == Post.order("created_at")
|
466
|
+
# search.results # == Post.order("title")
|
467
|
+
#
|
468
|
+
# @param order_name [String, Symbol]
|
469
|
+
# @param direction [:asc, :desc, nil]
|
470
|
+
# @return [TalentScout::ModelSearch]
|
471
|
+
# @raise [ArgumentError]
|
472
|
+
# if +order_name+ is invalid
|
473
|
+
def toggle_order(order_name, direction = nil)
|
474
|
+
definition = self.class.order_type.definitions[order_name]
|
475
|
+
raise ArgumentError, "`#{order_name}` is not a valid order" unless definition
|
476
|
+
direction ||= order_directions[order_name] == :asc ? :desc : :asc
|
477
|
+
with(order: definition.choice_for_direction(direction))
|
478
|
+
end
|
479
|
+
|
480
|
+
# Iterates over a specified {criteria}'s defined choices. If the
|
481
|
+
# given block accepts a 2nd argument, a boolean will be passed
|
482
|
+
# indicating whether that choice is currently used by the subject
|
483
|
+
# search object. If no block is given, an +Enumerator+ will be
|
484
|
+
# returned.
|
485
|
+
#
|
486
|
+
#
|
487
|
+
# @example With block
|
488
|
+
# class PostSearch < TalentScout::ModelSearch
|
489
|
+
# criteria :category, choices: %w[science tech engineering math]
|
490
|
+
# end
|
491
|
+
#
|
492
|
+
# search = PostSearch.new(category: "math")
|
493
|
+
#
|
494
|
+
# search.each_choice(:category) do |choice, chosen|
|
495
|
+
# puts "<li class=\"#{'active' if chosen}\">#{choice}</li>"
|
496
|
+
# end
|
497
|
+
#
|
498
|
+
#
|
499
|
+
# @example Without block
|
500
|
+
# class PostSearch < TalentScout::ModelSearch
|
501
|
+
# criteria :category, choices: %w[science tech engineering math]
|
502
|
+
# end
|
503
|
+
#
|
504
|
+
# search = PostSearch.new(category: "math")
|
505
|
+
#
|
506
|
+
# search.each_choice(:category).to_a
|
507
|
+
# # == ["science", "tech", "engineering", "math"]
|
508
|
+
#
|
509
|
+
# search.each_choice(:category).map do |choice, chosen|
|
510
|
+
# chosen ? "<b>#{choice}</b>" : choice
|
511
|
+
# end
|
512
|
+
# # == ["science", "tech", "engineering", "<b>math</b>"]
|
513
|
+
#
|
514
|
+
#
|
515
|
+
# @overload each_choice(criteria_name, &block)
|
516
|
+
# @param criteria_name [String, Symbol]
|
517
|
+
# @yieldparam choice [String]
|
518
|
+
# @return [void]
|
519
|
+
#
|
520
|
+
# @overload each_choice(criteria_name, &block)
|
521
|
+
# @param criteria_name [String, Symbol]
|
522
|
+
# @yieldparam choice [String]
|
523
|
+
# @yieldparam chosen [Boolean]
|
524
|
+
# @return [void]
|
525
|
+
#
|
526
|
+
# @overload each_choice(criteria_name)
|
527
|
+
# @param criteria_name [String, Symbol]
|
528
|
+
# @return [Enumerator]
|
529
|
+
#
|
530
|
+
# @raise [ArgumentError]
|
531
|
+
# if +criteria_name+ is invalid, or the specified criteria does
|
532
|
+
# not define choices
|
533
|
+
def each_choice(criteria_name, &block)
|
534
|
+
criteria_name = criteria_name.to_s
|
535
|
+
type = self.class.attribute_types.fetch(criteria_name, nil)
|
536
|
+
unless type.is_a?(ChoiceType)
|
537
|
+
raise ArgumentError, "`#{criteria_name}` is not a criteria with choices"
|
538
|
+
end
|
539
|
+
return to_enum(:each_choice, criteria_name) unless block
|
540
|
+
|
541
|
+
value_after_cast = attribute_set[criteria_name].value
|
542
|
+
type.mapping.each do |choice, value|
|
543
|
+
chosen = value_after_cast.equal?(value)
|
544
|
+
block.arity >= 2 ? block.call(choice, chosen) : block.call(choice)
|
545
|
+
end
|
546
|
+
end
|
547
|
+
|
548
|
+
# Returns a +HashWithIndifferentAccess+ with a key for each defined
|
549
|
+
# {order}. Each key's associated value indicates that order's
|
550
|
+
# currently applied direction -- +:asc+, +:desc+, or +nil+ if the
|
551
|
+
# order is not applied. Note that only one order can be applied at
|
552
|
+
# a time, so only one value in the Hash, at most, will be non-+nil+.
|
553
|
+
#
|
554
|
+
# @example
|
555
|
+
# class PostSearch < TalentScout::ModelSearch
|
556
|
+
# order :title
|
557
|
+
# order :created_at
|
558
|
+
# end
|
559
|
+
#
|
560
|
+
# PostSearch.new(order: "title").order_directions # == { title: :asc, created_at: nil }
|
561
|
+
# PostSearch.new(order: "title DESC").order_directions # == { title: :desc, created_at: nil }
|
562
|
+
# PostSearch.new(order: "created_at").order_directions # == { title: nil, created_at: :asc }
|
563
|
+
# PostSearch.new().order_directions # == { title: nil, created_at: nil }
|
564
|
+
#
|
565
|
+
# @return [ActiveSupport::HashWithIndifferentAccess]
|
566
|
+
def order_directions
|
567
|
+
@order_directions ||= begin
|
568
|
+
order_after_cast = attribute_set.fetch("order", nil).try(&:value)
|
569
|
+
self.class.order_type.definitions.transform_values{ nil }.
|
570
|
+
merge!(self.class.order_type.obverse_mapping[order_after_cast] || {})
|
571
|
+
end.freeze
|
572
|
+
end
|
573
|
+
|
574
|
+
# @!visibility private
|
575
|
+
def to_query_params
|
576
|
+
attribute_set.values_before_type_cast.
|
577
|
+
select{|key, value| attribute_set[key].changed? }
|
578
|
+
end
|
579
|
+
|
580
|
+
# @!visibility private
|
581
|
+
# HACK Implemented by ActiveRecord but not ActiveModel. Expected by
|
582
|
+
# some third-party form builders, e.g. Simple Form.
|
583
|
+
def has_attribute?(name)
|
584
|
+
self.class.attribute_types.key?(name.to_s)
|
585
|
+
end
|
586
|
+
|
587
|
+
# @!visibility private
|
588
|
+
# HACK Implemented by ActiveRecord but not ActiveModel. Expected by
|
589
|
+
# some third-party form builders, e.g. Simple Form.
|
590
|
+
def type_for_attribute(name)
|
591
|
+
self.class.attribute_types[name.to_s]
|
592
|
+
end
|
593
|
+
|
594
|
+
private
|
595
|
+
|
596
|
+
# @!visibility private
|
597
|
+
def self.criteria_list
|
598
|
+
@criteria_list ||= self == ModelSearch ? [] : self.superclass.criteria_list.dup
|
599
|
+
end
|
600
|
+
|
601
|
+
# @!visibility private
|
602
|
+
def self.order_type
|
603
|
+
@order_type ||= self == ModelSearch ? OrderType.new : self.superclass.order_type.dup
|
604
|
+
end
|
605
|
+
|
606
|
+
def attribute_set
|
607
|
+
@attributes # private instance variable from ActiveModel::Attributes ...YOLO!
|
608
|
+
end
|
609
|
+
|
610
|
+
end
|
611
|
+
end
|