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.
data/.gitmodules ADDED
@@ -0,0 +1,6 @@
1
+ [submodule "vendor/rails"]
2
+ path = vendor/rails
3
+ url = git://github.com/rails/rails.git
4
+ [submodule "vendor/arel"]
5
+ path = vendor/arel
6
+ url = git://github.com/ernie/arel.git
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ source :rubygems
2
+ gem "arel", ">= 0.4.0"
3
+ gem "rails", ">= 3.0.0.beta4"
4
+ group :test do
5
+ gem "rake"
6
+ gem "shoulda"
7
+ end
data/README.rdoc CHANGED
@@ -1,23 +1,28 @@
1
1
  = MetaSearch
2
2
 
3
- Extensible searching for your form_for enjoyment.
3
+ MetaSearch is extensible searching for your form_for enjoyment. It “wraps” one of your ActiveRecord models, providing methods that allow you to build up search conditions against that model, and has a few extra form helpers to simplify sorting and supplying multiple parameters to your condition methods as well.
4
4
 
5
5
  == Getting Started
6
6
 
7
- Add a line to your Gemfile:
7
+ In your Gemfile:
8
8
 
9
- gem "meta_search"
9
+ gem "meta_search" # Last officially released gem
10
+ # gem "meta_search", :git => "git://github.com/ernie/meta_search.git" # Track git repo
11
+
12
+ or, to install as a plugin:
13
+
14
+ rails plugin install git://github.com/ernie/meta_search.git
10
15
 
11
16
  In your controller:
12
17
 
13
18
  def index
14
19
  @search = Article.search(params[:search])
15
- @articles = @search.all
20
+ @articles = @search.all # or @search.relation to lazy load in view
16
21
  end
17
22
 
18
23
  In your view:
19
24
 
20
- <% form_for :search, @search, :html => {:method => :get} do |f| %>
25
+ <%= form_for @search, :url => articles_path, :html => {:method => :get} do |f| %>
21
26
  <%= f.label :title_contains %>
22
27
  <%= f.text_field :title_contains %><br />
23
28
  <%= f.label :comments_created_at_greater_than, 'With comments after' %>
@@ -25,21 +30,107 @@ In your view:
25
30
  <!-- etc... -->
26
31
  <%= f.submit %>
27
32
  <% end %>
28
-
33
+
29
34
  The default Where types are listed at MetaSearch::Where. Options for the search method are documented at MetaSearch::Searches::Base.
30
35
 
31
36
  == Advanced usage
32
37
 
38
+ === Narrowing the scope of a search
39
+
40
+ While the most common use case is to simply call Model.search(params[:search]), there
41
+ may be times where you want to scope your search more tightly. For instance, only allowing
42
+ users to search their own projects (assuming a current_user method returning the current user):
43
+
44
+ @search = current_user.projects.search(params[:search])
45
+
46
+ Or, you can build up any relation you like and call the search method on that object:
47
+
48
+ @projects_with_awesome_users_search =
49
+ Project.joins(:user).where(:users => {:awesome => true}).search(params[:search])
50
+
51
+ === Multi-level associations
52
+
53
+ MetaSearch will allow you traverse your associations in one form, generating the necessary
54
+ joins along the way. If you have the following models...
55
+
56
+ class Company < ActiveRecord::Base
57
+ has_many :developers
58
+ end
59
+
60
+ class Developer < ActiveRecord::Base
61
+ belongs_to :company
62
+ has_many :notes
63
+ end
64
+
65
+ ...you can do this in your form to search your companies by developers with certain notes:
66
+
67
+ <%= f.text_field :developers_notes_note %>
68
+
69
+ You can travel forward and back through the associations, so this would also work (though
70
+ be entirely pointless in this case):
71
+
72
+ <%= f.text_field :developers_notes_developer_company_name %>
73
+
74
+ However, to prevent abuse, this is limited to associations of a total "depth" of 5 levels.
75
+ This means that while starting from a Company model, as above, you could do
76
+ Company -> :developers -> :notes -> :developer -> :company, which has gotten you right
77
+ back where you started, but "travels" through 5 models total.
78
+
33
79
  === Adding a new Where
34
80
 
35
81
  If none of the built-in search criteria work for you, you can add a new Where (or 5). To do so,
36
82
  create an initializer (<tt>/config/initializers/meta_search.rb</tt>, for instance) and add lines
37
83
  like:
38
84
 
39
- MetaSearch::Where.add :between, :btw, {:condition => 'BETWEEN', :substitutions => '? AND ?'}
85
+ MetaSearch::Where.add :between, :btw,
86
+ :predicate => :in,
87
+ :types => [:integer, :float, :decimal, :date, :datetime, :timestamp, :time],
88
+ :formatter => Proc.new {|param| Range.new(param.first, param.last)},
89
+ :validator => Proc.new {|param|
90
+ param.is_a?(Array) && !(param[0].blank? || param[1].blank?)
91
+ }
40
92
 
41
93
  See MetaSearch::Where for info on the supported options.
42
94
 
95
+ === Accessing custom search methods (and named scopes!)
96
+
97
+ MetaSearch can be given access to any class method on your model to extend its search capabilities.
98
+ The only rule is that the method must return an ActiveRecord::Relation so that MetaSearch can
99
+ continue to extend the search with other attributes. Conveniently, scopes (formerly "named scopes")
100
+ do this already.
101
+
102
+ Consider the following model:
103
+
104
+ class Company < ActiveRecord::Base
105
+ has_many :slackers, :class_name => "Developer", :conditions => {:slacker => true}
106
+ scope :backwards_name, lambda {|name| where(:name => name.reverse)}
107
+ scope :with_slackers_by_name_and_salary_range,
108
+ lambda {|name, low, high|
109
+ joins(:slackers).where(:developers => {:name => name, :salary => low..high})
110
+ }
111
+ end
112
+
113
+ To allow MetaSearch access to a model method, including a named scope, just use
114
+ <tt>search_methods</tt> in the model:
115
+
116
+ search_methods :backwards_name
117
+
118
+ This will allow you to add a text field named :backwards_name to your search form, and
119
+ it will behave as you might expect.
120
+
121
+ In the case of the second scope, we have multiple parameters to pass in, of different
122
+ types. We can pass the following to <tt>search_methods</tt>:
123
+
124
+ search_methods :with_slackers_by_name_and_salary_range,
125
+ :splat_param => true, :type => [:string, :integer, :integer]
126
+
127
+ MetaSearch needs us to tell it that we don't want to keep the array supplied to it as-is, but
128
+ "splat" it when passing it to the model method. Regarding <tt>:types</tt>: In this case,
129
+ ActiveRecord would have been smart enough to handle the typecasting for us, but I wanted to
130
+ demonstrate how we can tell MetaSearch that a given parameter is of a specific database "column type." This is just a hint MetaSearch uses in the same way it does when casting "Where" params based
131
+ on the DB column being searched. It's also important so that things like dates get handled
132
+ properly by FormBuilder.
133
+
43
134
  === multiparameter_field
44
135
 
45
136
  The example Where above adds support for a "between" search, which requires an array with
@@ -53,49 +144,97 @@ MetaSearch adds a helper for this:
53
144
  lets you sandwich a list of fields, each in hash format, between the attribute and the usual
54
145
  options hash. See MetaSearch::Helpers::FormBuilder for more info.
55
146
 
147
+ === Compound conditions (any/all)
148
+
149
+ All Where types automatically get an "any" and "all" variant. This has the same name and
150
+ aliases as the original, but is suffixed with _any and _all, for an "OR" or "AND" search,
151
+ respectively. So, if you want to provide the user with 5 different search boxes to enter
152
+ possible article titles:
153
+
154
+ <%= f.multiparameter_field :title_contains_any,
155
+ *5.times.inject([]) {|a, b| a << {:field_type => :text_field}} +
156
+ [:size => 10] %>
157
+
56
158
  === check_boxes and collection_check_boxes
57
159
 
58
160
  If you need to get an array into your where, and you don't care about parameter order,
59
161
  you might choose to use a select or collection_select with multiple selection enabled,
60
162
  but everyone hates multiple selection boxes. MetaSearch adds a couple of additional
61
163
  helpers, +check_boxes+ and +collection_check_boxes+ to handle multiple selections in a
62
- more visually appealing manner. It can be called with or without a block, so something
63
- like this is possible:
164
+ more visually appealing manner. They can be called with or without a block. Without a
165
+ block, you get an array of MetaSearch::Check objects to do with as you please.
64
166
 
65
- <table>
66
- <th colspan="2">How many heads?</th>
167
+ With a block, each check is yielded to your template, like so:
168
+
169
+ <h4>How many heads?</h4>
170
+ <ul>
67
171
  <% f.check_boxes :number_of_heads_in,
68
- [['One', 1], ['Two', 2], ['Three', 3]], :class => 'checkboxy' do |c| %>
69
- <tr>
70
- <td><%= c[:check_box] %></td>
71
- <td><%= c[:label] %></td>
72
- </tr>
172
+ [['One', 1], ['Two', 2], ['Three', 3]], :class => 'checkboxy' do |check| %>
173
+ <li>
174
+ <%= check.box %>
175
+ <%= check.label %>
176
+ </li>
73
177
  <% end %>
74
- </table>
178
+ </ul>
75
179
 
76
180
  Again, full documentation is in MetaSearch::Helpers::FormBuilder.
77
181
 
78
- === Excluding attributes and associations
182
+ === Sorting columns
183
+
184
+ If you'd like to sort by a specific column in your results (the attributes of the base model)
185
+ or an association column then supply the <tt>meta_sort</tt> parameter in your form.
186
+ The parameter takes the form <tt>column.direction</tt> where +column+ is the column name or
187
+ underscore-separated association_column combination, and +direction+ is one of "asc" or "desc"
188
+ for ascending or descending, respectively.
189
+
190
+ Normally, you won't supply this parameter yourself, but instead will use the helper method
191
+ <tt>sort_link</tt> in your views, like so:
192
+
193
+ <%= sort_link @search, :title %>
194
+ <%= sort_link @search, :created_at %>
195
+
196
+ The <tt>@search</tt> object is the instance of MetaSearch::Builder you got back earlier from
197
+ your controller. The other required parameter is the attribute name itself. Optionally,
198
+ you can provide a string as a 3rd parameter to override the default link name, and then
199
+ additional hashed for the +options+ and +html_options+ hashes for link_to.
200
+
201
+ All <tt>sort_link</tt>-generated links will have the CSS class sort_link, as well as a
202
+ directional class (ascending or descending) if the link is for a currently sorted column,
203
+ for your styling enjoyment.
79
204
 
80
- If you'd like to prevent certain associations or attributes from being searchable, you can control
81
- this inside your models:
205
+ This feature should hopefully help out those of you migrating from SearchLogic, and a thanks
206
+ goes out to Ben Johnson for the HTML entities used for the up and down arrows, which provide
207
+ a nice default look.
208
+
209
+ === Including/excluding attributes and associations
210
+
211
+ If you'd like to allow only certain associations or attributes to be searched, you can do
212
+ so inside your models
82
213
 
83
214
  class Article < ActiveRecord::Base
84
- metasearch_exclude_attr :some_private_data, :another_private_column
85
- metasearch_exclude_assoc :an_association_that_should_not_be_searched, :and_another
215
+ attr_searchable :some_public_data, :some_more_searchable_stuff
216
+ assoc_searchable :search_this_association_why_dontcha
86
217
  end
87
218
 
88
- You get the idea. Excluded attributes on a model will be honored across associations, so
89
- if an Article <tt>has_many :comments</tt> and the Comment model looks something like this:
219
+ If you'd rather blacklist attributes and associations rather than whitelist, use the
220
+ <tt>attr_unsearchable</tt> and <tt>assoc_unsearchable</tt> method instead. If a
221
+ whitelist is supplied, it takes precedence.
222
+
223
+ Excluded attributes on a model will be honored across associations, so if an Article
224
+ <tt>has_many :comments</tt> and the Comment model looks something like this:
90
225
 
91
- Comment < ActiveRecord::Base
226
+ class Comment < ActiveRecord::Base
92
227
  validates_presence_of :user_id, :body
93
- metasearch_exclude_attr :user_id
228
+ attr_unsearchable :user_id
94
229
  end
95
230
 
96
231
  Then your call to <tt>Article.search</tt> will allow <tt>:comments_body_contains</tt>
97
232
  but not <tt>:comments_user_id_equals</tt> to be passed.
98
233
 
234
+ == Reporting issues
235
+
236
+ Please report any issues using {Lighthouse}[http://metautonomous.lighthouseapp.com/projects/53012-metasearch/]. Thanks in advance for helping me improve MetaSearch!
237
+
99
238
  == Copyright
100
239
 
101
240
  Copyright (c) 2010 {Ernie Miller}[http://metautonomo.us]. See LICENSE for details.
data/Rakefile CHANGED
@@ -5,12 +5,20 @@ begin
5
5
  require 'jeweler'
6
6
  Jeweler::Tasks.new do |gem|
7
7
  gem.name = "meta_search"
8
- gem.summary = %Q{ActiveRecord 3 object-based searching.}
9
- gem.description = %Q{Adds a search method to your ActiveRecord models which returns an object to be used in form_for while constructing a search. Works with Rails 3 only.}
8
+ gem.summary = %Q{ActiveRecord 3 object-based searching for your form_for enjoyment.}
9
+ gem.description = %Q{
10
+ Allows simple search forms to be created against an AR3 model
11
+ and its associations, has useful view helpers for sort links
12
+ and multiparameter fields as well.
13
+ }
10
14
  gem.email = "ernie@metautonomo.us"
11
- gem.homepage = "http://metautonomo.us"
15
+ gem.homepage = "http://metautonomo.us/projects/metasearch/"
12
16
  gem.authors = ["Ernie Miller"]
13
- gem.add_development_dependency "activerecord", ">= 3.0.0.beta"
17
+ gem.add_development_dependency "shoulda"
18
+ gem.add_dependency "activerecord", ">= 3.0.0.beta4"
19
+ gem.add_dependency "activesupport", ">= 3.0.0.beta4"
20
+ gem.add_dependency "actionpack", ">= 3.0.0.beta4"
21
+ gem.add_dependency "arel", ">= 0.4.0"
14
22
  # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
15
23
  end
16
24
  Jeweler::GemcutterTasks.new
@@ -21,6 +29,10 @@ end
21
29
  require 'rake/testtask'
22
30
  Rake::TestTask.new(:test) do |test|
23
31
  test.libs << 'lib' << 'test'
32
+ test.libs << 'vendor/rails/activerecord/lib'
33
+ test.libs << 'vendor/rails/activesupport/lib'
34
+ test.libs << 'vendor/rails/actionpack/lib'
35
+ test.libs << 'vendor/arel/lib'
24
36
  test.pattern = 'test/**/test_*.rb'
25
37
  test.verbose = true
26
38
  end
@@ -38,7 +50,8 @@ rescue LoadError
38
50
  end
39
51
  end
40
52
 
41
- task :test => :check_dependencies
53
+ # Don't check dependencies on test, we're using vendored libraries
54
+ # task :test => :check_dependencies
42
55
 
43
56
  task :default => :test
44
57
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.3.0
1
+ 0.5.0
data/lib/meta_search.rb CHANGED
@@ -6,26 +6,36 @@ module MetaSearch
6
6
  BOOLEANS = [:boolean]
7
7
  ALL_TYPES = NUMBERS + STRINGS + DATES + TIMES + BOOLEANS
8
8
 
9
+ # Change this only if you know what you're doing. It's here for your protection.
10
+ MAX_JOIN_DEPTH = 5
11
+
9
12
  DEFAULT_WHERES = [
10
13
  ['equals', 'eq'],
11
- ['does_not_equal', 'ne', {:types => ALL_TYPES, :condition => '!='}],
12
- ['contains', 'like', {:types => STRINGS, :condition => 'LIKE', :formatter => '"%#{param}%"'}],
13
- ['does_not_contain', 'nlike', {:types => STRINGS, :condition => 'NOT LIKE', :formatter => '"%#{param}%"'}],
14
- ['starts_with', 'sw', {:types => STRINGS, :condition => 'LIKE', :formatter => '"#{param}%"'}],
15
- ['does_not_start_with', 'dnsw', {:types => STRINGS, :condition => 'NOT LIKE', :formatter => '"%#{param}%"'}],
16
- ['ends_with', 'ew', {:types => STRINGS, :condition => 'LIKE', :formatter => '"%#{param}"'}],
17
- ['does_not_end_with', 'dnew', {:types => STRINGS, :condition => 'NOT LIKE', :formatter => '"%#{param}"'}],
18
- ['greater_than', 'gt', {:types => (NUMBERS + DATES + TIMES), :condition => '>'}],
19
- ['less_than', 'lt', {:types => (NUMBERS + DATES + TIMES), :condition => '<'}],
20
- ['greater_than_or_equal_to', 'gte', {:types => (NUMBERS + DATES + TIMES), :condition => '>='}],
21
- ['less_than_or_equal_to', 'lte', {:types => (NUMBERS + DATES + TIMES), :condition => '<='}],
22
- ['in', {:types => ALL_TYPES, :condition => 'IN', :substitutions => '(?)', :keep_arrays => true}],
23
- ['not_in', 'ni', {:types => ALL_TYPES, :condition => 'NOT IN', :substitutions => '(?)', :keep_arrays => true}]
14
+ ['does_not_equal', 'ne', 'not_eq', {:types => ALL_TYPES, :predicate => :not_eq}],
15
+ ['contains', 'like', 'matches', {:types => STRINGS, :predicate => :matches, :formatter => '"%#{param}%"'}],
16
+ ['does_not_contain', 'nlike', 'not_matches', {:types => STRINGS, :predicate => :not_matches, :formatter => '"%#{param}%"'}],
17
+ ['starts_with', 'sw', {:types => STRINGS, :predicate => :matches, :formatter => '"#{param}%"'}],
18
+ ['does_not_start_with', 'dnsw', {:types => STRINGS, :predicate => :not_matches, :formatter => '"%#{param}%"'}],
19
+ ['ends_with', 'ew', {:types => STRINGS, :predicate => :matches, :formatter => '"%#{param}"'}],
20
+ ['does_not_end_with', 'dnew', {:types => STRINGS, :predicate => :not_matches, :formatter => '"%#{param}"'}],
21
+ ['greater_than', 'gt', {:types => (NUMBERS + DATES + TIMES), :predicate => :gt}],
22
+ ['less_than', 'lt', {:types => (NUMBERS + DATES + TIMES), :predicate => :lt}],
23
+ ['greater_than_or_equal_to', 'gte', 'gteq', {:types => (NUMBERS + DATES + TIMES), :predicate => :gteq}],
24
+ ['less_than_or_equal_to', 'lte', 'lteq', {:types => (NUMBERS + DATES + TIMES), :predicate => :lteq}],
25
+ ['in', {:types => ALL_TYPES, :predicate => :in}],
26
+ ['not_in', 'ni', 'not_in', {:types => ALL_TYPES, :predicate => :not_in}]
24
27
  ]
25
-
26
- RELATION_METHODS = [:joins, :includes, :all, :count, :to_sql, :paginate, :find_each, :first, :last, :each]
28
+
29
+ RELATION_METHODS = [:joins, :includes, :to_a, :all, :count, :to_sql, :paginate, :autojoin, :find_each, :first, :last, :each, :arel]
27
30
  end
28
31
 
29
- if defined?(::Rails::Railtie)
30
- require 'meta_search/railtie'
31
- end
32
+ require 'active_record'
33
+ require 'action_view'
34
+ require 'action_controller'
35
+ require 'meta_search/searches/active_record'
36
+ require 'meta_search/helpers'
37
+
38
+ ActiveRecord::Base.send(:include, MetaSearch::Searches::ActiveRecord)
39
+ ActionView::Helpers::FormBuilder.send(:include, MetaSearch::Helpers::FormBuilder)
40
+ ActionController::Base.helper(MetaSearch::Helpers::UrlHelper)
41
+ ActionController::Base.helper(MetaSearch::Helpers::FormHelper)
@@ -4,6 +4,12 @@ require 'meta_search/where'
4
4
  require 'meta_search/utility'
5
5
 
6
6
  module MetaSearch
7
+ # Raised if you try to access a relation that's joining too many tables to itself.
8
+ # This is designed to prevent a malicious user from accessing something like
9
+ # :developers_company_developers_company_developers_company_developers_company_...,
10
+ # resulting in a query that could cause issues for your database server.
11
+ class JoinDepthError < StandardError; end
12
+
7
13
  # Builder is the workhorse of MetaSearch -- it is the class that handles dynamically generating
8
14
  # methods based on a supplied model, and is what gets instantiated when you call your model's search
9
15
  # method. Builder doesn't generate any methods until they're needed, using method_missing to compare
@@ -21,59 +27,79 @@ module MetaSearch
21
27
  class Builder
22
28
  include ModelCompatibility
23
29
  include Utility
24
-
25
- attr_reader :base, :search_attributes, :relation, :join_dependency
26
- delegate *RELATION_METHODS, :to => :relation
30
+
31
+ attr_reader :base, :search_attributes, :join_dependency
32
+ delegate *RELATION_METHODS + [:to => :relation]
27
33
 
28
34
  # Initialize a new Builder. Requires a base model to wrap, and supports a couple of options
29
35
  # for how it will expose this model and its associations to your controllers/views.
30
- def initialize(base, opts = {})
31
- @base = base
36
+ def initialize(base_or_relation, opts = {})
37
+ @relation = base_or_relation.scoped
38
+ @base = @relation.klass
32
39
  @opts = opts
33
- @associations = {}
34
- @join_dependency = ActiveRecord::Associations::ClassMethods::JoinDependency.new(@base, [], nil)
40
+ @join_dependency = ActiveRecord::Associations::ClassMethods::JoinDependency.new(@base, @relation.joins_values, nil)
35
41
  @search_attributes = {}
36
- @relation = @base.scoped
37
42
  end
38
-
39
- # Return the column info for the given model attribute (if not excluded as outlined above)
40
- def column(attr)
41
- @base.columns_hash[attr.to_s] if self.includes_attribute?(attr)
43
+
44
+ def relation
45
+ enforce_join_depth_limit!
46
+ @relation
42
47
  end
43
-
44
- # Return the association reflection for the named association (if not excluded as outlined
45
- # above)
46
- def association(association)
47
- if self.includes_association?(association)
48
- @associations[association.to_sym] ||= @base.reflect_on_association(association.to_sym)
48
+
49
+ def get_column(column, base = @base)
50
+ if base._metasearch_include_attributes.blank?
51
+ base.columns_hash[column.to_s] unless base._metasearch_exclude_attributes.include?(column.to_s)
52
+ else
53
+ base.columns_hash[column.to_s] if base._metasearch_include_attributes.include?(column.to_s)
49
54
  end
50
55
  end
51
-
52
- # Return the column info for given association and column (if the association is not
53
- # excluded from search)
54
- def association_column(association, attr)
55
- if self.includes_association?(association)
56
- assoc = self.association(association)
57
- assoc.klass.columns_hash[attr.to_s] unless assoc.klass._metasearch_exclude_attributes.include?(attr.to_s)
56
+
57
+ def get_association(assoc, base = @base)
58
+ if base._metasearch_include_associations.blank?
59
+ base.reflect_on_association(assoc.to_sym) unless base._metasearch_exclude_associations.include?(assoc.to_s)
60
+ else
61
+ base.reflect_on_association(assoc.to_sym) if base._metasearch_include_associations.include?(assoc.to_s)
58
62
  end
59
63
  end
60
-
61
- def included_attributes
62
- @included_attributes ||= @base.column_names - @base._metasearch_exclude_attributes
63
- end
64
-
65
- def includes_attribute?(attr)
66
- self.included_attributes.include?(attr.to_s)
64
+
65
+ def get_attribute(name, parent = @join_dependency.join_base)
66
+ attribute = nil
67
+ if get_column(name, parent.active_record)
68
+ if parent.is_a?(ActiveRecord::Associations::ClassMethods::JoinDependency::JoinAssociation)
69
+ relation = parent.relation.is_a?(Array) ? parent.relation.last : parent.relation
70
+ attribute = relation.table[name]
71
+ else
72
+ attribute = @relation.table[name]
73
+ end
74
+ elsif (segments = name.to_s.split(/_/)).size > 1
75
+ remainder = []
76
+ found_assoc = nil
77
+ while remainder.unshift(segments.pop) && segments.size > 0 && !found_assoc do
78
+ if found_assoc = get_association(segments.join('_'), parent.active_record)
79
+ join = build_or_find_association(found_assoc.name, parent)
80
+ attribute = get_attribute(remainder.join('_'), join)
81
+ end
82
+ end
83
+ end
84
+ attribute
67
85
  end
68
-
69
- def included_associations
70
- @included_associations ||= @base.reflect_on_all_associations.map {|a| a.name.to_s} - @base._metasearch_exclude_associations
86
+
87
+ def base_includes_association?(base, assoc)
88
+ if base._metasearch_include_associations.blank?
89
+ base.reflect_on_association(assoc.to_sym) unless base._metasearch_exclude_associations.include?(assoc.to_s)
90
+ else
91
+ base.reflect_on_association(assoc.to_sym) if base._metasearch_include_associations.include?(assoc.to_s)
92
+ end
71
93
  end
72
-
73
- def includes_association?(assoc)
74
- self.included_associations.include?(assoc.to_s)
94
+
95
+ def base_includes_attribute?(base, attribute)
96
+ if base._metasearch_include_attributes.blank?
97
+ base.column_names.detect(attribute.to_s) unless base._metasearch_exclude_attributes.include?(attribute.to_s)
98
+ else
99
+ base.column_names.detect(attribute.to_s) if base._metasearch_include_attributes.include?(attribute.to_s)
100
+ end
75
101
  end
76
-
102
+
77
103
  # Build the search with the given search options. Options are in the form of a hash
78
104
  # with keys matching the names creted by the Builder's "wheres" as outlined in
79
105
  # MetaSearch::Where
@@ -85,139 +111,166 @@ module MetaSearch
85
111
  assign_attributes(opts)
86
112
  self
87
113
  end
88
-
114
+
89
115
  private
90
-
116
+
91
117
  def method_missing(method_id, *args, &block)
92
- if match = method_id.to_s.match(/^(.*)\(([0-9]+).*\)$/)
118
+ if method_id.to_s =~ /^meta_sort=?$/
119
+ build_sort_method
120
+ self.send(method_id, *args)
121
+ elsif match = method_id.to_s.match(/^(.*)\(([0-9]+).*\)$/) # Multiparameter reader
93
122
  method_name, index = match.captures
94
123
  vals = self.send(method_name)
95
124
  vals.is_a?(Array) ? vals[index.to_i - 1] : nil
125
+ elsif match = matches_named_method(method_id)
126
+ build_named_method(match)
127
+ self.send(method_id, *args)
96
128
  elsif match = matches_attribute_method(method_id)
97
- condition, attribute, association = match.captures.reverse
98
- build_method(association, attribute, condition)
129
+ attribute, predicate = match.captures
130
+ build_attribute_method(attribute, predicate)
99
131
  self.send(preferred_method_name(method_id), *args)
100
- elsif match = matches_where_method(method_id)
101
- condition = match.captures.first
102
- build_where_method(condition, Where.new(condition))
103
- self.send(method_id, *args)
104
132
  else
105
133
  super
106
134
  end
107
135
  end
108
-
109
- def build_method(association, attribute, suffix)
110
- if association.blank?
111
- build_attribute_method(attribute, suffix)
112
- else
113
- build_association_method(association, attribute, suffix)
136
+
137
+ def build_sort_method
138
+ singleton_class.instance_eval do
139
+ define_method(:meta_sort) do
140
+ search_attributes['meta_sort']
141
+ end
142
+
143
+ define_method(:meta_sort=) do |val|
144
+ column, direction = val.split('.')
145
+ direction ||= 'asc'
146
+ if ['asc','desc'].include?(direction) && attribute = get_attribute(column)
147
+ search_attributes['meta_sort'] = val
148
+ @relation = @relation.order(attribute.send(direction).to_sql)
149
+ end
150
+ end
114
151
  end
115
152
  end
116
-
117
- def build_association_method(association, attribute, type)
118
- metaclass.instance_eval do
119
- define_method("#{association}_#{attribute}_#{type}") do
120
- search_attributes["#{association}_#{attribute}_#{type}"]
121
- end
122
-
123
- define_method("#{association}_#{attribute}_#{type}=") do |val|
124
- search_attributes["#{association}_#{attribute}_#{type}"] = cast_attributes(association_type_for(association, attribute), val)
125
- where = Where.new(type)
126
- if where.valid_substitutions?(search_attributes["#{association}_#{attribute}_#{type}"])
127
- join = build_or_find_association(association)
128
- self.send("add_#{type}_where", join.aliased_table_name, attribute, search_attributes["#{association}_#{attribute}_#{type}"])
153
+
154
+ def column_type(name, base = @base)
155
+ type = nil
156
+ if column = get_column(name, base)
157
+ type = column.type
158
+ elsif (segments = name.split(/_/)).size > 1
159
+ remainder = []
160
+ found_assoc = nil
161
+ while remainder.unshift(segments.pop) && segments.size > 0 && !found_assoc do
162
+ if found_assoc = get_association(segments.join('_'), base)
163
+ type = column_type(remainder.join('_'), found_assoc.klass)
129
164
  end
130
165
  end
131
166
  end
167
+ type
132
168
  end
133
-
134
- def build_attribute_method(attribute, type)
135
- metaclass.instance_eval do
136
- define_method("#{attribute}_#{type}") do
137
- search_attributes["#{attribute}_#{type}"]
169
+
170
+ def build_named_method(name)
171
+ meth = @base._metasearch_methods[name]
172
+ singleton_class.instance_eval do
173
+ define_method(name) do
174
+ search_attributes[name]
138
175
  end
139
-
140
- define_method("#{attribute}_#{type}=") do |val|
141
- search_attributes["#{attribute}_#{type}"] = cast_attributes(type_for(attribute), val)
142
- where = Where.new(type)
143
- if where.valid_substitutions?(search_attributes["#{attribute}_#{type}"])
144
- self.send("add_#{type}_where", @base.table_name, attribute, search_attributes["#{attribute}_#{type}"])
176
+
177
+ define_method("#{name}=") do |val|
178
+ search_attributes[name] = meth.cast_param(val)
179
+ if meth.validate(search_attributes[name])
180
+ return_value = meth.eval(@relation, search_attributes[name])
181
+ if return_value.is_a?(ActiveRecord::Relation)
182
+ @relation = return_value
183
+ else
184
+ raise NonRelationReturnedError, "Custom search methods must return an ActiveRecord::Relation. #{name} returned a #{return_value.class}"
185
+ end
145
186
  end
146
187
  end
147
188
  end
148
189
  end
149
-
150
- def build_where_method(condition, where)
151
- metaclass.instance_eval do
152
- define_method("add_#{condition}_where") do |table, attribute, *args|
153
- args.flatten! unless where.keep_arrays?
154
- @relation = @relation.where(
155
- "#{quote_table_name table}.#{quote_column_name attribute} " +
156
- "#{where.condition} #{where.substitutions}", *format_params(where.formatter, *args)
157
- )
190
+
191
+ def build_attribute_method(attribute, predicate)
192
+ singleton_class.instance_eval do
193
+ define_method("#{attribute}_#{predicate}") do
194
+ search_attributes["#{attribute}_#{predicate}"]
195
+ end
196
+
197
+ define_method("#{attribute}_#{predicate}=") do |val|
198
+ search_attributes["#{attribute}_#{predicate}"] = cast_attributes(column_type(attribute), val)
199
+ where = Where.new(predicate)
200
+ if where.validate(search_attributes["#{attribute}_#{predicate}"])
201
+ arel_attribute = get_attribute(attribute)
202
+ @relation = where.eval(@relation, arel_attribute, search_attributes["#{attribute}_#{predicate}"])
203
+ end
158
204
  end
159
205
  end
160
206
  end
161
-
162
- def format_params(formatter, *params)
163
- par = params.map {|p| formatter.call(p)}
164
- end
165
-
166
- def build_or_find_association(association)
207
+
208
+ def build_or_find_association(association, parent = @join_dependency.join_base)
167
209
  found_association = @join_dependency.join_associations.detect do |assoc|
168
- assoc.reflection.name == association.to_sym
210
+ assoc.reflection.name == association.to_sym &&
211
+ assoc.parent == parent
169
212
  end
170
213
  unless found_association
171
- @relation = @relation.joins(association.to_sym)
172
- @join_dependency.send(:build, association.to_sym, @join_dependency.join_base)
214
+ @join_dependency.send(:build, association, parent)
173
215
  found_association = @join_dependency.join_associations.last
216
+ @relation = @relation.joins(found_association)
174
217
  end
175
218
  found_association
176
219
  end
177
-
220
+
221
+ def enforce_join_depth_limit!
222
+ raise JoinDepthError, "Maximum join depth of #{MAX_JOIN_DEPTH} exceeded." if @join_dependency.join_associations.detect {|ja|
223
+ gauge_depth_of_join_association(ja) > MAX_JOIN_DEPTH
224
+ }
225
+ end
226
+
227
+ def gauge_depth_of_join_association(ja)
228
+ 1 + (ja.respond_to?(:parent) ? gauge_depth_of_join_association(ja.parent) : 0)
229
+ end
230
+
231
+ def matches_named_method(name)
232
+ method_name = name.to_s.sub(/\=$/, '')
233
+ return method_name if @base._metasearch_methods.has_key?(method_name)
234
+ end
235
+
178
236
  def matches_attribute_method(method_id)
179
237
  method_name = preferred_method_name(method_id)
180
238
  where = Where.new(method_id) rescue nil
181
239
  return nil unless method_name && where
182
240
  match = method_name.match("^(.*)_(#{where.name})=?$")
183
- attribute, condition = match.captures
184
- if where.types.include?(type_for(attribute))
241
+ attribute, predicate = match.captures
242
+ if where.types.include?(column_type(attribute))
185
243
  return match
186
- elsif match = matches_association(method_name, attribute, condition)
187
- association, attribute = match.captures
188
- return match if where.types.include?(association_type_for(association, attribute))
189
244
  end
190
245
  nil
191
246
  end
192
-
193
- def preferred_method_name(method_id)
194
- method_name = method_id.to_s
195
- where = Where.new(method_name) rescue nil
196
- return nil unless where
197
- where.aliases.each do |a|
198
- break if method_name.sub!(/#{a}(=?)$/, "#{where.name}\\1")
199
- end
200
- method_name
201
- end
202
-
203
- def matches_association(method_id, attribute, condition)
247
+
248
+ def matches_association_method(method_id)
249
+ method_name = preferred_method_name(method_id)
250
+ where = Where.new(method_id) rescue nil
251
+ return nil unless method_name && where
252
+ match = method_name.match("^(.*)_(#{where.name})=?$")
253
+ attribute, predicate = match.captures
204
254
  self.included_associations.each do |association|
205
255
  test_attribute = attribute.dup
206
256
  if test_attribute.gsub!(/^#{association}_/, '') &&
207
- match = method_id.to_s.match("^(#{association})_(#{test_attribute})_(#{condition})=?$")
208
- return match
257
+ match = method_name.match("^(#{association})_(#{test_attribute})_(#{predicate})=?$")
258
+ return match if where.types.include?(association_type_for(association, test_attribute))
209
259
  end
210
260
  end
211
261
  nil
212
262
  end
213
-
214
- def matches_where_method(method_id)
215
- if match = method_id.to_s.match(/^add_(.*)_where$/)
216
- condition = match.captures.first
217
- Where.get(condition) ? match : nil
263
+
264
+ def preferred_method_name(method_id)
265
+ method_name = method_id.to_s
266
+ where = Where.new(method_name) rescue nil
267
+ return nil unless where
268
+ where.aliases.each do |a|
269
+ break if method_name.sub!(/#{a}(=?)$/, "#{where.name}\\1")
218
270
  end
271
+ method_name
219
272
  end
220
-
273
+
221
274
  def assign_attributes(opts)
222
275
  opts.each_pair do |k, v|
223
276
  self.send("#{k}=", v)
@@ -228,17 +281,17 @@ module MetaSearch
228
281
  column = self.column(attribute)
229
282
  column.type if column
230
283
  end
231
-
284
+
232
285
  def class_for(attribute)
233
286
  column = self.column(attribute)
234
287
  column.klass if column
235
288
  end
236
-
289
+
237
290
  def association_type_for(association, attribute)
238
291
  column = self.association_column(association, attribute)
239
292
  column.type if column
240
293
  end
241
-
294
+
242
295
  def association_class_for(association, attribute)
243
296
  column = self.association_column(association, attribute)
244
297
  column.klass if column