talent_scout 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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