talent_scout 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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,5 @@
1
+ en:
2
+ helpers:
3
+ submit:
4
+ <%= config[:param_key] %>:
5
+ create: "Search"
@@ -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,4 @@
1
+ <% module_namespacing do -%>
2
+ class <%= class_name %>Search < TalentScout::ModelSearch
3
+ end
4
+ <% 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,4 @@
1
+ require 'test_helper'
2
+
3
+ class <%= class_name %>SearchTest < ActiveSupport::TestCase
4
+ 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