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