meta_search 0.3.0 → 0.5.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.
@@ -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