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