meta_search_hub 1.1.3

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.
@@ -0,0 +1,129 @@
1
+ require 'meta_search/utility'
2
+
3
+ module MetaSearch
4
+ # MetaSearch can be given access to any class method on your model to extend its search capabilities.
5
+ # The only rule is that the method must return an ActiveRecord::Relation so that MetaSearch can
6
+ # continue to extend the search with other attributes. Conveniently, scopes (formerly "named scopes")
7
+ # do this already.
8
+ #
9
+ # Consider the following model:
10
+ #
11
+ # class Company < ActiveRecord::Base
12
+ # has_many :slackers, :class_name => "Developer", :conditions => {:slacker => true}
13
+ # scope :backwards_name, lambda {|name| where(:name => name.reverse)}
14
+ # scope :with_slackers_by_name_and_salary_range,
15
+ # lambda {|name, low, high|
16
+ # joins(:slackers).where(:developers => {:name => name, :salary => low..high})
17
+ # }
18
+ # end
19
+ #
20
+ # To allow MetaSearch access to a model method, including a named scope, just use
21
+ # <tt>search_methods</tt> in the model:
22
+ #
23
+ # search_methods :backwards_name
24
+ #
25
+ # This will allow you to add a text field named :backwards_name to your search form, and
26
+ # it will behave as you might expect.
27
+ #
28
+ # In the case of the second scope, we have multiple parameters to pass in, of different
29
+ # types. We can pass the following to <tt>search_methods</tt>:
30
+ #
31
+ # search_methods :with_slackers_by_name_and_salary_range,
32
+ # :splat_param => true, :type => [:string, :integer, :integer]
33
+ #
34
+ # MetaSearch needs us to tell it that we don't want to keep the array supplied to it as-is, but
35
+ # "splat" it when passing it to the model method. And in this case, ActiveRecord would have been
36
+ # smart enough to handle the typecasting for us, but I wanted to demonstrate how we can tell
37
+ # MetaSearch that a given parameter is of a specific database "column type." This is just a hint
38
+ # MetaSearch uses in the same way it does when casting "Where" params based on the DB column
39
+ # being searched. It's also important so that things like dates get handled properly by FormBuilder.
40
+ #
41
+ # _NOTE_: If you do supply an array, rather than a single type value, to <tt>:type</tt>, MetaSearch
42
+ # will enforce that any array supplied for input by your forms has the correct number of elements
43
+ # for your eventual method.
44
+ #
45
+ # Besides <tt>:splat_param</tt> and <tt>:type</tt>, search_methods accept the same <tt>:formatter</tt>
46
+ # and <tt>:validator</tt> options that you would use when adding a new MetaSearch::Where:
47
+ #
48
+ # <tt>formatter</tt> is the Proc that will do any formatting to the variable passed to your method.
49
+ # The default proc is <tt>{|param| param}</tt>, which doesn't really do anything. If you pass a
50
+ # string, it will be +eval+ed in the context of this Proc.
51
+ #
52
+ # If your method will do a LIKE search against its parameter, you might want to pass:
53
+ #
54
+ # :formatter => '"%#{param}%"'
55
+ #
56
+ # Be sure to single-quote the string, so that variables aren't interpolated until later. If in doubt,
57
+ # just use a Proc, like so:
58
+ #
59
+ # :formatter => Proc.new {|param| "%#{param}%"}
60
+ #
61
+ # <tt>validator</tt> is the Proc that will be used to check whether a parameter supplied to the
62
+ # method is valid. If it is not valid, it won't be used in the query. The default is
63
+ # <tt>{|param| !param.blank?}</tt>, so that empty parameters aren't added to the search, but you
64
+ # can get more complex if you desire. Validations are run after typecasting, so you can check
65
+ # the class of your parameters, for instance.
66
+ class Method
67
+ include Utility
68
+
69
+ attr_reader :name, :formatter, :validator, :type
70
+
71
+ def initialize(name, opts ={})
72
+ raise ArgumentError, "Name parameter required" if name.blank?
73
+ @name = name
74
+ @type = opts[:type] || :string
75
+ @splat_param = opts[:splat_param] || false
76
+ @formatter = opts[:formatter] || Proc.new {|param| param}
77
+ if @formatter.is_a?(String)
78
+ formatter = @formatter
79
+ @formatter = Proc.new {|param| eval formatter}
80
+ end
81
+ unless @formatter.respond_to?(:call)
82
+ raise ArgumentError, "Invalid formatter for #{name}, should be a Proc or String."
83
+ end
84
+ @validator = opts[:validator] || Proc.new {|param| !param.blank?}
85
+ unless @validator.respond_to?(:call)
86
+ raise ArgumentError, "Invalid validator for #{name}, should be a Proc."
87
+ end
88
+ end
89
+
90
+ # Cast the parameter to the type specified in the Method's <tt>type</tt>
91
+ def cast_param(param)
92
+ if type.is_a?(Array)
93
+ unless param.is_a?(Array) && param.size == type.size
94
+ num_params = param.is_a?(Array) ? param.size : 1
95
+ raise ArgumentError, "Parameters supplied to #{name} could not be type cast -- #{num_params} values supplied, #{type.size} expected"
96
+ end
97
+ type.each_with_index do |t, i|
98
+ param[i] = cast_attributes(t, param[i])
99
+ end
100
+ param
101
+ else
102
+ cast_attributes(type, param)
103
+ end
104
+ end
105
+
106
+ # Evaluate the method in the context of the supplied relation and parameter
107
+ def evaluate(relation, param)
108
+ if splat_param?
109
+ relation.send(name, *format_param(param))
110
+ else
111
+ relation.send(name, format_param(param))
112
+ end
113
+ end
114
+
115
+ def splat_param?
116
+ !!@splat_param
117
+ end
118
+
119
+ # Format a parameter for searching using the Method's defined formatter.
120
+ def format_param(param)
121
+ formatter.call(param)
122
+ end
123
+
124
+ # Validate the parameter for use in a search using the Method's defined validator.
125
+ def validate(param)
126
+ validator.call(param)
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,75 @@
1
+ require 'meta_search/utility'
2
+
3
+ module MetaSearch
4
+
5
+ module ModelCompatibility
6
+
7
+ def self.included(base)
8
+ base.extend ClassMethods
9
+ end
10
+
11
+ def persisted?
12
+ false
13
+ end
14
+
15
+ def to_key
16
+ nil
17
+ end
18
+
19
+ def to_param
20
+ nil
21
+ end
22
+
23
+ def to_model
24
+ self
25
+ end
26
+ end
27
+
28
+ class Name < String
29
+ attr_reader :singular, :plural, :element, :collection, :partial_path, :human, :param_key, :route_key, :i18n_key
30
+ alias_method :cache_key, :collection
31
+
32
+ def initialize
33
+ super("Search")
34
+ @singular = "search".freeze
35
+ @plural = "searches".freeze
36
+ @element = "search".freeze
37
+ @human = "Search".freeze
38
+ @collection = "meta_search/searches".freeze
39
+ @partial_path = "#{@collection}/#{@element}".freeze
40
+ @param_key = "search".freeze
41
+ @route_key = "searches".freeze
42
+ @i18n_key = :meta_search
43
+ end
44
+ end
45
+
46
+ module ClassMethods
47
+ include Utility
48
+
49
+ def model_name
50
+ @_model_name ||= Name.new
51
+ end
52
+
53
+ def human_attribute_name(attribute, options = {})
54
+ method_name = preferred_method_name(attribute)
55
+
56
+ defaults = [:"meta_search.attributes.#{klass.model_name.i18n_key}.#{method_name || attribute}"]
57
+
58
+ if method_name
59
+ predicate = Where.get(method_name)[:name]
60
+ predicate_attribute = method_name.sub(/_#{predicate}=?$/, '')
61
+ predicate_attributes = predicate_attribute.split(/_or_/).map { |att|
62
+ klass.human_attribute_name(att)
63
+ }.join(" #{I18n.translate(:"meta_search.or", :default => 'or')} ")
64
+ defaults << :"meta_search.predicates.#{predicate}"
65
+ end
66
+
67
+ defaults << options.delete(:default) if options[:default]
68
+ defaults << attribute.to_s.humanize
69
+
70
+ options.reverse_merge! :count => 1, :default => defaults, :attribute => predicate_attributes || klass.human_attribute_name(attribute)
71
+ I18n.translate(defaults.shift, options)
72
+ end
73
+ end
74
+
75
+ end
@@ -0,0 +1,203 @@
1
+ require 'active_support/concern'
2
+ require 'meta_search/method'
3
+ require 'meta_search/builder'
4
+
5
+ module MetaSearch
6
+ module Searches
7
+
8
+ module ActiveRecord
9
+
10
+ def self.included(base)
11
+ base.extend ClassMethods
12
+
13
+ base.class_eval do
14
+ class_attribute :_metasearch_include_attributes, :_metasearch_exclude_attributes
15
+ class_attribute :_metasearch_include_associations, :_metasearch_exclude_associations
16
+ class_attribute :_metasearch_methods
17
+ self._metasearch_include_attributes =
18
+ self._metasearch_exclude_attributes =
19
+ self._metasearch_exclude_associations =
20
+ self._metasearch_include_associations = {}
21
+ self._metasearch_methods = {}
22
+ end
23
+ end
24
+
25
+ module ClassMethods
26
+ # Prepares the search to run against your model. Returns an instance of
27
+ # MetaSearch::Builder, which behaves pretty much like an ActiveRecord::Relation,
28
+ # in that it doesn't actually query the database until you do something that
29
+ # requires it to do so.
30
+ #
31
+ # Options:
32
+ #
33
+ # * +params+ - a hash of valid searches with keys that are valid according to
34
+ # the docs in MetaSearch::Where.
35
+ # * +opts+ - A hash of additional information that will be passed through to
36
+ # the search's Builder object. +search_key+, if present, will override the
37
+ # default param name, 'search', in any sort_links generated by this Builder.
38
+ # All other keys are passed untouched to the builder, and available from the
39
+ # Builder's +options+ reader for use in :if blocks supplied to attr_searchable
40
+ # and friends.
41
+ def metasearch(params = nil, opts = nil)
42
+ builder = Searches.for(self).new(self, opts || {})
43
+ builder.build(params || {})
44
+ end
45
+
46
+ alias_method :search, :metasearch unless respond_to?(:search)
47
+
48
+ def _metasearch_method_authorized?(name, metasearch_object)
49
+ name = name.to_s
50
+ meth = self._metasearch_methods[name]
51
+ meth && (meth[:if] ? meth[:if].call(metasearch_object) : true)
52
+ end
53
+
54
+ def _metasearch_attribute_authorized?(name, metasearch_object)
55
+ name = name.to_s
56
+ if self._metasearch_include_attributes.empty?
57
+ !_metasearch_excludes_attribute?(name, metasearch_object)
58
+ else
59
+ _metasearch_includes_attribute?(name, metasearch_object)
60
+ end
61
+ end
62
+
63
+ def _metasearch_association_authorized?(name, metasearch_object)
64
+ name = name.to_s
65
+ if self._metasearch_include_associations.empty?
66
+ !_metasearch_excludes_association?(name, metasearch_object)
67
+ else
68
+ _metasearch_includes_association?(name, metasearch_object)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ # Excludes model attributes from searchability. This means that searches can't be created against
75
+ # these columns, whether the search is based on this model, or the model's attributes are being
76
+ # searched by association from another model. If a Comment <tt>belongs_to :article</tt> but declares
77
+ # <tt>attr_unsearchable :user_id</tt> then <tt>Comment.search</tt> won't accept parameters
78
+ # like <tt>:user_id_equals</tt>, nor will an Article.search accept the parameter
79
+ # <tt>:comments_user_id_equals</tt>.
80
+ def attr_unsearchable(*args)
81
+ if table_exists?
82
+ opts = args.extract_options!
83
+ args.flatten.each do |attr|
84
+ attr = attr.to_s
85
+ raise(ArgumentError, "No persisted attribute (column) named #{attr} in #{self}") unless self.columns_hash.has_key?(attr)
86
+ self._metasearch_exclude_attributes = self._metasearch_exclude_attributes.merge(
87
+ attr => {
88
+ :if => opts[:if]
89
+ }
90
+ )
91
+ end
92
+ end
93
+ end
94
+
95
+ # Like <tt>attr_unsearchable</tt>, but operates as a whitelist rather than blacklist. If both
96
+ # <tt>attr_searchable</tt> and <tt>attr_unsearchable</tt> are present, the latter
97
+ # is ignored.
98
+ def attr_searchable(*args)
99
+ if table_exists?
100
+ opts = args.extract_options!
101
+ args.flatten.each do |attr|
102
+ attr = attr.to_s
103
+ raise(ArgumentError, "No persisted attribute (column) named #{attr} in #{self}") unless self.columns_hash.has_key?(attr)
104
+ self._metasearch_include_attributes = self._metasearch_include_attributes.merge(
105
+ attr => {
106
+ :if => opts[:if]
107
+ }
108
+ )
109
+ end
110
+ end
111
+ end
112
+
113
+ # Excludes model associations from searchability. This mean that searches can't be created against
114
+ # these associations. An article that <tt>has_many :comments</tt> but excludes comments from
115
+ # searching by declaring <tt>assoc_unsearchable :comments</tt> won't make any of the
116
+ # <tt>comments_*</tt> methods available.
117
+ def assoc_unsearchable(*args)
118
+ opts = args.extract_options!
119
+ args.flatten.each do |assoc|
120
+ assoc = assoc.to_s
121
+ raise(ArgumentError, "No such association #{assoc} in #{self}") unless self.reflect_on_all_associations.map {|a| a.name.to_s}.include?(assoc)
122
+ self._metasearch_exclude_associations = self._metasearch_exclude_associations.merge(
123
+ assoc => {
124
+ :if => opts[:if]
125
+ }
126
+ )
127
+ end
128
+ end
129
+
130
+ # As with <tt>attr_searchable</tt> this is the whitelist version of
131
+ # <tt>assoc_unsearchable</tt>
132
+ def assoc_searchable(*args)
133
+ opts = args.extract_options!
134
+ args.flatten.each do |assoc|
135
+ assoc = assoc.to_s
136
+ raise(ArgumentError, "No such association #{assoc} in #{self}") unless self.reflect_on_all_associations.map {|a| a.name.to_s}.include?(assoc)
137
+ self._metasearch_include_associations = self._metasearch_include_associations.merge(
138
+ assoc => {
139
+ :if => opts[:if]
140
+ }
141
+ )
142
+ end
143
+ end
144
+
145
+ def search_methods(*args)
146
+ opts = args.extract_options!
147
+ authorizer = opts.delete(:if)
148
+ args.flatten.map(&:to_s).each do |arg|
149
+ self._metasearch_methods = self._metasearch_methods.merge(
150
+ arg => {
151
+ :method => MetaSearch::Method.new(arg, opts),
152
+ :if => authorizer
153
+ }
154
+ )
155
+ end
156
+ end
157
+
158
+ alias_method :search_method, :search_methods
159
+
160
+ def _metasearch_includes_attribute?(name, metasearch_object)
161
+ attr = self._metasearch_include_attributes[name]
162
+ attr && (attr[:if] ? attr[:if].call(metasearch_object) : true)
163
+ end
164
+
165
+ def _metasearch_excludes_attribute?(name, metasearch_object)
166
+ attr = self._metasearch_exclude_attributes[name]
167
+ attr && (attr[:if] ? attr[:if].call(metasearch_object) : true)
168
+ end
169
+
170
+ def _metasearch_includes_association?(name, metasearch_object)
171
+ assoc = self._metasearch_include_associations[name]
172
+ assoc && (assoc[:if] ? assoc[:if].call(metasearch_object) : true)
173
+ end
174
+
175
+ def _metasearch_excludes_association?(name, metasearch_object)
176
+ assoc = self._metasearch_exclude_associations[name]
177
+ assoc && (assoc[:if] ? assoc[:if].call(metasearch_object) : true)
178
+ end
179
+
180
+ end
181
+ end
182
+
183
+ def self.for(klass)
184
+ DISPATCH[klass.name]
185
+ end
186
+
187
+ private
188
+
189
+ DISPATCH = Hash.new do |hash, klass_name|
190
+ class_name = klass_name.gsub('::', '_')
191
+ hash[klass_name] = module_eval <<-RUBY_EVAL
192
+ class #{class_name} < MetaSearch::Builder
193
+ def self.klass
194
+ ::#{klass_name}
195
+ end
196
+ end
197
+
198
+ #{class_name}
199
+ RUBY_EVAL
200
+ end
201
+
202
+ end
203
+ end
@@ -0,0 +1,110 @@
1
+ require 'meta_search/exceptions'
2
+
3
+ module MetaSearch
4
+ module Utility #:nodoc:
5
+
6
+ TRUE_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE'].to_set
7
+ FALSE_VALUES = [false, 0, '0', 'f', 'F', 'false', 'FALSE'].to_set
8
+
9
+ private
10
+
11
+ def preferred_method_name(method_id)
12
+ method_name = method_id.to_s
13
+ where = Where.new(method_name) rescue nil
14
+ return nil unless where
15
+ where.aliases.each do |a|
16
+ break if method_name.sub!(/#{a}(=?)$/, "#{where.name}\\1")
17
+ end
18
+ method_name
19
+ end
20
+
21
+ def array_of_strings?(o)
22
+ o.is_a?(Array) && o.all?{|obj| obj.is_a?(String)}
23
+ end
24
+
25
+ def array_of_arrays?(vals)
26
+ vals.is_a?(Array) && vals.first.is_a?(Array)
27
+ end
28
+
29
+ def array_of_dates?(vals)
30
+ vals.is_a?(Array) && vals.first.respond_to?(:to_time)
31
+ end
32
+
33
+ def cast_attributes(type, vals)
34
+ if array_of_arrays?(vals)
35
+ vals.map! {|v| cast_attributes(type, v)}
36
+ # Need to make sure not to kill multiparam dates/times
37
+ elsif vals.is_a?(Array) && (array_of_dates?(vals) || !(DATES+TIMES).include?(type))
38
+ vals.map! {|v| cast_attribute(type, v)}
39
+ else
40
+ cast_attribute(type, vals)
41
+ end
42
+ end
43
+
44
+ def cast_attribute(type, val)
45
+ case type
46
+ when *STRINGS
47
+ val.respond_to?(:to_s) ? val.to_s : String.new(val)
48
+ when *DATES
49
+ if val.respond_to?(:to_date)
50
+ val.to_date rescue nil
51
+ else
52
+ y, m, d = *[val].flatten
53
+ m ||= 1
54
+ d ||= 1
55
+ Date.new(y,m,d) rescue nil
56
+ end
57
+ when *TIMES
58
+ if val.is_a?(Array)
59
+ y, m, d, hh, mm, ss = *[val].flatten
60
+ Time.zone.local(y, m, d, hh, mm, ss) rescue nil
61
+ else
62
+ unless val.acts_like?(:time)
63
+ val = val.is_a?(String) ? Time.zone.parse(val) : val.to_time rescue val
64
+ end
65
+ val.in_time_zone rescue nil
66
+ end
67
+ when *BOOLEANS
68
+ if val.is_a?(String) && val.blank?
69
+ nil
70
+ else
71
+ TRUE_VALUES.include?(val)
72
+ end
73
+ when :integer
74
+ val.blank? ? nil : val.to_i
75
+ when :float
76
+ val.blank? ? nil : val.to_f
77
+ when :decimal
78
+ if val.blank?
79
+ nil
80
+ elsif val.class == BigDecimal
81
+ val
82
+ elsif val.respond_to?(:to_d)
83
+ val.to_d
84
+ else
85
+ val.to_s.to_d
86
+ end
87
+ else
88
+ raise TypeCastError, "Unable to cast columns of type #{type}"
89
+ end
90
+ end
91
+
92
+ def collapse_multiparameter_options(opts)
93
+ opts.keys.each do |k|
94
+ if k.include?("(")
95
+ real_attribute, position = k.split(/\(|\)/)
96
+ cast = %w(a s i).include?(position.last) ? position.last : nil
97
+ position = position.to_i - 1
98
+ value = opts.delete(k)
99
+ opts[real_attribute] ||= []
100
+ opts[real_attribute][position] = if cast
101
+ (value.blank? && cast == 'i') ? nil : value.send("to_#{cast}")
102
+ else
103
+ value
104
+ end
105
+ end
106
+ end
107
+ opts
108
+ end
109
+ end
110
+ end