meta_search 0.3.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,3 +1,4 @@
1
1
  module MetaSearch
2
2
  class TypeCastError < StandardError; end
3
+ class NonRelationReturnedError < StandardError; end
3
4
  end
@@ -0,0 +1,3 @@
1
+ require 'meta_search/helpers/form_builder'
2
+ require 'meta_search/helpers/form_helper'
3
+ require 'meta_search/helpers/url_helper'
@@ -0,0 +1,152 @@
1
+ require 'action_view'
2
+ require 'action_view/template'
3
+ module MetaSearch
4
+ Check = Struct.new(:box, :label)
5
+
6
+ module Helpers
7
+ module FormBuilder
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ self.field_helpers += ['multiparameter_field', 'check_boxes', 'collection_check_boxes']
12
+ end
13
+
14
+ # Like other form_for field methods (text_field, hidden_field, password_field) etc,
15
+ # but takes a list of hashes between the +method+ parameter and the trailing option hash,
16
+ # if any, to specify a number of fields to create in multiparameter fashion.
17
+ #
18
+ # Each hash *must* contain a :field_type option, which specifies a form_for method, and
19
+ # _may_ contain an optional :type_cast option, with one of the typical multiparameter
20
+ # type cast characters. Any remaining options will be merged with the defaults specified
21
+ # in the trailing option hash and passed along when creating that field.
22
+ #
23
+ # For example...
24
+ #
25
+ # <%= f.multiparameter_field :moderations_value_between,
26
+ # {:field_type => :text_field, :class => 'first'},
27
+ # {:field_type => :text_field, :type_cast => 'i'},
28
+ # :size => 5 %>
29
+ #
30
+ # ...will create the following HTML:
31
+ #
32
+ # <input class="first" id="search_moderations_value_between(1)"
33
+ # name="search[moderations_value_between(1)]" size="5" type="text" />
34
+ #
35
+ # <input id="search_moderations_value_between(2i)"
36
+ # name="search[moderations_value_between(2i)]" size="5" type="text" />
37
+ #
38
+ # As with any multiparameter input fields, these will be concatenated into an
39
+ # array and passed to the attribute named by the first parameter for assignment.
40
+ def multiparameter_field(method, *args)
41
+ defaults = has_multiparameter_defaults?(args) ? args.pop : {}
42
+ raise ArgumentError, "No multiparameter fields specified" if args.blank?
43
+ html = ''.html_safe
44
+ args.each_with_index do |field, index|
45
+ type = field.delete(:field_type) || raise(ArgumentError, "No :field_type specified.")
46
+ cast = field.delete(:type_cast) || ''
47
+ opts = defaults.merge(field)
48
+ html.safe_concat(
49
+ @template.send(
50
+ type.to_s,
51
+ @object_name,
52
+ (method.to_s + "(#{index + 1}#{cast})"),
53
+ objectify_options(opts))
54
+ )
55
+ end
56
+ html
57
+ end
58
+
59
+ # Behaves almost exactly like the select method, but instead of generating a select tag,
60
+ # generates <tt>MetaSearch::Check</tt>s. These consist of two attributes, +box+ and +label+,
61
+ # which are (unsurprisingly) the HTML for the check box and the label. Called without a block,
62
+ # this method will return an array of check boxes. Called with a block, it will yield each
63
+ # check box to your template.
64
+ #
65
+ # *Parameters:*
66
+ #
67
+ # * +method+ - The method name on the form_for object
68
+ # * +choices+ - An array of arrays, the first value in each element is the text for the
69
+ # label, and the last is the value for the checkbox
70
+ # * +options+ - An options hash to be passed through to the checkboxes
71
+ #
72
+ # *Examples:*
73
+ #
74
+ # <b>Simple formatting:</b>
75
+ #
76
+ # <h4>How many heads?</h4>
77
+ # <ul>
78
+ # <% f.check_boxes :number_of_heads_in,
79
+ # [['One', 1], ['Two', 2], ['Three', 3]], :class => 'checkboxy' do |check| %>
80
+ # <li>
81
+ # <%= check.box %>
82
+ # <%= check.label %>
83
+ # </li>
84
+ # <% end %>
85
+ # </ul>
86
+ #
87
+ # This example will output the checkboxes and labels in an unordered list format.
88
+ #
89
+ # <b>Grouping:</b>
90
+ #
91
+ # Chain <tt>in_groups_of(<num>, false)</tt> on check_boxes like so:
92
+ # <h4>How many heads?</h4>
93
+ # <p>
94
+ # <% f.check_boxes(:number_of_heads_in,
95
+ # [['One', 1], ['Two', 2], ['Three', 3]],
96
+ # :class => 'checkboxy').in_groups_of(2, false) do |checks| %>
97
+ # <% checks.each do |check| %>
98
+ # <%= check.box %>
99
+ # <%= check.label %>
100
+ # <% end %>
101
+ # <br />
102
+ # <% end %>
103
+ # </p>
104
+ def check_boxes(method, choices = [], options = {}, &block)
105
+ unless choices.first.respond_to?(:first) && choices.first.respond_to?(:last)
106
+ raise ArgumentError, 'invalid choice array specified'
107
+ end
108
+ collection_check_boxes(method, choices, :last, :first, options, &block)
109
+ end
110
+
111
+ # Just like +check_boxes+, but this time you can pass in a collection, value, and text method,
112
+ # as with collection_select.
113
+ #
114
+ # Example:
115
+ #
116
+ # <%= f.collection_check_boxes :head_sizes_in, HeadSize.all,
117
+ # :id, :name, :class => 'headcheck' do |check| %>
118
+ # <%= check.box %> <%= check.label %>
119
+ # <% end %>
120
+ def collection_check_boxes(method, collection, value_method, text_method, options = {}, &block)
121
+ check_boxes = []
122
+ collection.each do |choice|
123
+ text = choice.send(text_method)
124
+ value = choice.send(value_method)
125
+ check = MetaSearch::Check.new
126
+ check.box = @template.check_box_tag(
127
+ "#{@object_name}[#{method}][]",
128
+ value,
129
+ [@object.send(method)].flatten.include?(value),
130
+ options.merge(:id => [@object_name, method.to_s, value.to_s.underscore].join('_'))
131
+ )
132
+ check.label = @template.label_tag([@object_name, method.to_s, value.to_s.underscore].join('_'),
133
+ text)
134
+ if block_given?
135
+ yield check
136
+ else
137
+ check_boxes << check
138
+ end
139
+ end
140
+ check_boxes unless block_given?
141
+ end
142
+
143
+ private
144
+
145
+ # If the last element of the arguments to multiparameter_field has no :field_type
146
+ # key, we assume it's got some defaults to be used in the other hashes.
147
+ def has_multiparameter_defaults?(args)
148
+ args.size > 1 && args.last.is_a?(Hash) && !args.last.has_key?(:field_type)
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,20 @@
1
+ module MetaSearch
2
+ module Helpers
3
+ module FormHelper
4
+ def apply_form_for_options!(object_or_array, options)
5
+ if object_or_array.is_a?(Array) && object_or_array.first.is_a?(MetaSearch::Builder)
6
+ builder = object_or_array.first
7
+ html_options = {
8
+ :class => options[:as] ? "#{options[:as]}_search" : "#{builder.base.to_s.underscore}_search",
9
+ :id => options[:as] ? "#{options[:as]}_search" : "#{builder.base.to_s.underscore}_search",
10
+ :method => :get }
11
+ options[:html] ||= {}
12
+ options[:html].reverse_merge!(html_options)
13
+ options[:url] ||= polymorphic_path(builder.base)
14
+ else
15
+ super
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,39 @@
1
+ module MetaSearch
2
+ module Helpers
3
+ module UrlHelper
4
+
5
+ def sort_link(builder, attribute, *args)
6
+ raise ArgumentError, "Need a MetaSearch::Builder search object as first param!" unless builder.is_a?(MetaSearch::Builder)
7
+ attr_name = attribute.to_s
8
+ name = (args.size > 0 && !args.first.is_a?(Hash)) ? args.shift.to_s : attr_name.humanize
9
+ prev_attr, prev_order = builder.search_attributes['meta_sort'].to_s.split('.')
10
+ current_order = prev_attr == attr_name ? prev_order : nil
11
+ new_order = current_order == 'asc' ? 'desc' : 'asc'
12
+ options = args.first.is_a?(Hash) ? args.shift : {}
13
+ html_options = args.first.is_a?(Hash) ? args.shift : {}
14
+ css = ['sort_link', current_order].compact.join(' ')
15
+ html_options[:class] = [css, html_options[:class]].compact.join(' ')
16
+ options.merge!(
17
+ 'search' => builder.search_attributes.merge(
18
+ 'meta_sort' => [attr_name, new_order].join('.')
19
+ )
20
+ )
21
+ link_to [ERB::Util.h(name), order_indicator_for(current_order)].compact.join(' ').html_safe,
22
+ url_for(options),
23
+ html_options
24
+ end
25
+
26
+ private
27
+
28
+ def order_indicator_for(order)
29
+ if order == 'asc'
30
+ '&#9650;'
31
+ elsif order == 'desc'
32
+ '&#9660;'
33
+ else
34
+ nil
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -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 eval(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
@@ -1,8 +1,42 @@
1
1
  module MetaSearch
2
2
  # Just a little module to mix in so that ActionPack doesn't complain.
3
3
  module ModelCompatibility
4
- def new_record?
5
- false
4
+ def self.included(base)
5
+ base.extend ClassMethods
6
6
  end
7
+
8
+ # Force default "Update search" text
9
+ def persisted?
10
+ true
11
+ end
12
+
13
+ def to_key
14
+ nil
15
+ end
16
+
17
+ def to_param
18
+ nil
19
+ end
20
+
21
+ class Name < String
22
+ attr_reader :singular, :plural, :element, :collection, :partial_path, :human
23
+ alias_method :cache_key, :collection
24
+
25
+ def initialize
26
+ super("Search")
27
+ @singular = "search".freeze
28
+ @plural = "searches".freeze
29
+ @element = "search".freeze
30
+ @human = "Search".freeze
31
+ @collection = "meta_search/searches".freeze
32
+ @partial_path = "#{@collection}/#{@element}".freeze
33
+ end
34
+ end
35
+
36
+ module ClassMethods
37
+ def model_name
38
+ @_model_name ||= Name.new
39
+ end
40
+ end
7
41
  end
8
42
  end
@@ -1,18 +1,95 @@
1
- require 'meta_search/searches/base'
2
- require 'active_record'
1
+ require 'active_support/concern'
2
+ require 'meta_search/method'
3
+ require 'meta_search/builder'
3
4
 
4
5
  module MetaSearch::Searches
5
6
  module ActiveRecord
6
- include MetaSearch::Searches::Base
7
+ extend ActiveSupport::Concern
7
8
 
8
- # Mixes MetaSearch into ActiveRecord::Base.
9
- def self.enable!
10
- ::ActiveRecord::Base.class_eval do
11
- class_attribute :_metasearch_exclude_attributes
12
- class_attribute :_metasearch_exclude_associations
13
- self._metasearch_exclude_attributes = []
14
- self._metasearch_exclude_associations = []
15
- extend ActiveRecord
9
+ included do
10
+ class_attribute :_metasearch_include_attributes, :_metasearch_exclude_attributes
11
+ class_attribute :_metasearch_include_associations, :_metasearch_exclude_associations
12
+ class_attribute :_metasearch_methods
13
+ self._metasearch_include_attributes =
14
+ self._metasearch_exclude_attributes =
15
+ self._metasearch_exclude_associations =
16
+ self._metasearch_include_associations = []
17
+ self._metasearch_methods = {}
18
+
19
+ singleton_class.instance_eval do
20
+ alias_method :metasearch_include_attr, :attr_searchable
21
+ alias_method :metasearch_exclude_attr, :attr_unsearchable
22
+ alias_method :metasearch_include_assoc, :assoc_searchable
23
+ alias_method :metasearch_exclude_assoc, :assoc_unsearchable
24
+ end
25
+ end
26
+
27
+ module ClassMethods
28
+ # Prepares the search to run against your model. Returns an instance of
29
+ # MetaSearch::Builder, which behaves pretty much like an ActiveRecord::Relation,
30
+ # in that it doesn't actually query the database until you do something that
31
+ # requires it to do so.
32
+ def search(opts = {})
33
+ opts ||= {} # to catch nil params
34
+ search_options = opts.delete(:search_options) || {}
35
+ builder = MetaSearch::Builder.new(self, search_options)
36
+ builder.build(opts)
37
+ end
38
+
39
+ private
40
+
41
+ # Excludes model attributes from searchability. This means that searches can't be created against
42
+ # these columns, whether the search is based on this model, or the model's attributes are being
43
+ # searched by association from another model. If a Comment <tt>belongs_to :article</tt> but declares
44
+ # <tt>attr_unsearchable :user_id</tt> then <tt>Comment.search</tt> won't accept parameters
45
+ # like <tt>:user_id_equals</tt>, nor will an Article.search accept the parameter
46
+ # <tt>:comments_user_id_equals</tt>.
47
+ def attr_unsearchable(*args)
48
+ args.flatten.each do |attr|
49
+ attr = attr.to_s
50
+ raise(ArgumentError, "No persisted attribute (column) named #{attr} in #{self}") unless self.columns_hash.has_key?(attr)
51
+ self._metasearch_exclude_attributes = (self._metasearch_exclude_attributes + [attr]).uniq
52
+ end
53
+ end
54
+
55
+ # Like <tt>attr_unsearchable</tt>, but operates as a whitelist rather than blacklist. If both
56
+ # <tt>attr_searchable</tt> and <tt>attr_unsearchable</tt> are present, the latter
57
+ # is ignored.
58
+ def attr_searchable(*args)
59
+ args.flatten.each do |attr|
60
+ attr = attr.to_s
61
+ raise(ArgumentError, "No persisted attribute (column) named #{attr} in #{self}") unless self.columns_hash.has_key?(attr)
62
+ self._metasearch_include_attributes = (self._metasearch_include_attributes + [attr]).uniq
63
+ end
64
+ end
65
+
66
+ # Excludes model associations from searchability. This mean that searches can't be created against
67
+ # these associations. An article that <tt>has_many :comments</tt> but excludes comments from
68
+ # searching by declaring <tt>assoc_unsearchable :comments</tt> won't make any of the
69
+ # <tt>comments_*</tt> methods available.
70
+ def assoc_unsearchable(*args)
71
+ args.flatten.each do |assoc|
72
+ assoc = assoc.to_s
73
+ raise(ArgumentError, "No such association #{assoc} in #{self}") unless self.reflect_on_all_associations.map {|a| a.name.to_s}.include?(assoc)
74
+ self._metasearch_exclude_associations = (self._metasearch_exclude_associations + [assoc]).uniq
75
+ end
76
+ end
77
+
78
+ # As with <tt>attr_searchable</tt> this is the whitelist version of
79
+ # <tt>assoc_unsearchable</tt>
80
+ def assoc_searchable(*args)
81
+ args.flatten.each do |assoc|
82
+ assoc = assoc.to_s
83
+ raise(ArgumentError, "No such association #{assoc} in #{self}") unless self.reflect_on_all_associations.map {|a| a.name.to_s}.include?(assoc)
84
+ self._metasearch_include_associations = (self._metasearch_include_associations + [assoc]).uniq
85
+ end
86
+ end
87
+
88
+ def search_methods(*args)
89
+ opts = args.last.is_a?(Hash) ? args.pop : {}
90
+ args.flatten.map(&:to_s).each do |arg|
91
+ self._metasearch_methods[arg] = MetaSearch::Method.new(arg, opts)
92
+ end
16
93
  end
17
94
  end
18
95
  end