├── .document
├── .gitignore
├── .gitmodules
├── CHANGELOG
├── Gemfile
├── LICENSE
├── README.rdoc
├── Rakefile
├── VERSION
├── lib
├── meta_search.rb
└── meta_search
│ ├── builder.rb
│ ├── exceptions.rb
│ ├── helpers.rb
│ ├── helpers
│ ├── form_builder.rb
│ ├── form_helper.rb
│ └── url_helper.rb
│ ├── locale
│ └── en.yml
│ ├── method.rb
│ ├── model_compatibility.rb
│ ├── searches
│ └── active_record.rb
│ ├── utility.rb
│ └── where.rb
├── meta_search.gemspec
└── test
├── fixtures
├── companies.yml
├── company.rb
├── data_type.rb
├── data_types.yml
├── developer.rb
├── developers.yml
├── developers_projects.yml
├── note.rb
├── notes.yml
├── project.rb
├── projects.yml
└── schema.rb
├── helper.rb
├── locales
├── es.yml
└── flanders.yml
├── test_search.rb
└── test_view_helpers.rb
/.document:
--------------------------------------------------------------------------------
1 | README.rdoc
2 | lib/**/*.rb
3 | bin/*
4 | features/**/*.feature
5 | LICENSE
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## MAC OS
2 | .DS_Store
3 |
4 | ## TEXTMATE
5 | *.tmproj
6 | tmtags
7 |
8 | ## EMACS
9 | *~
10 | \#*
11 | .\#*
12 |
13 | ## VIM
14 | *.swp
15 |
16 | ## PROJECT::GENERAL
17 | coverage
18 | rdoc
19 | pkg
20 |
21 | ## PROJECT::SPECIFIC
22 | Gemfile.lock
23 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "vendor/arel"]
2 | path = vendor/arel
3 | url = git://github.com/rails/arel.git
4 | [submodule "vendor/rails"]
5 | path = vendor/rails
6 | url = git://github.com/rails/rails.git
7 |
--------------------------------------------------------------------------------
/CHANGELOG:
--------------------------------------------------------------------------------
1 | Changes since 1.0.4 (2011-04-08):
2 | * Add :join_type option to Builder to allow for using InnerJoin if desired
3 | (Stephen Pike)
4 | * Fix a memory leak in development mode (Bonias)
5 |
6 | Changes since 1.0.3 (2011-03-14):
7 | * Be sure not to override form_for options if super returns a non-true value,
8 | fixes a compatibility issue when using client_side_validation
9 |
10 | Changes since 1.0.1 (2011-01-18):
11 | * Include all non-boolean types in is_present and is_blank, to match
12 | documentation
13 | * Avoid setting alias to collection_check_boxes and check_boxes against
14 | the base. Fixes issues with SimpleForm compatibility.
15 | * Delegate page method to relation, for Kaminari support.
16 | * Don't check for existence of attributes if the table doesn't exist yet.
17 |
18 | Changes since 1.0.0 (2011-01-17):
19 | * Update polymorphic join support to play nicely with MetaWhere
20 |
21 | Changes since 0.9.11 (2011-01-06):
22 | * Doc updates only.
23 |
24 | Changes since 0.9.10 (2010-11-18):
25 | * Skip attempts to sort if someone passes an empty string to meta_sort
26 | * Allow conditions on search_methods, (attr|assoc)_(un)?searchable using :if.
27 | Option should be an object that responds to call and accepts the
28 | MetaSearch::Builder instance as a parameter. Unused options passed to the
29 | Model.search method will be available for your conditions to act on.
30 | * Access attribute setters if a param is supplied - @search.attr_name(val)
31 | behaves like @search.attr_name = val
32 |
33 | Changes since 0.9.9 (2010-11-15):
34 | * Fix bug introduced by new polymorphic belongs_to association code in
35 | honoring :url param to form_for
36 | * Support localization of predicate text in labels
37 | * Fix bug when accessing localizations for named search methods
38 |
39 | Changes since 0.9.8 (2010-10-20):
40 | * ARel 2.x and Rails 3.0.2 compatability
41 | * sort_link uses search_key from builder. Search_key defaults to "search"
42 | * sort_link will localize attribute names.
43 | * You can now create two scopes on your model named sort_by_something_asc
44 | and sort_by_something_desc, and sort_link will then allow you to specify
45 | :something as a parameter, then use your scope to perform custom sorting.
46 |
47 | Changes since 0.9.7 (2010-10-12):
48 | * Play nicely regardless of MetaWhere/MetaSearch load order.
49 | * Big fix - stop altering the supplied hash in Builder#build.
50 |
51 | Changes since 0.9.6 (2010-09-29):
52 | * Support _or_-separated conditions. I'm not crazy about 'em, but it's
53 | an oft-requested feature.
54 | * Support search on polymorphic belongs_to associations. Uses the same
55 | syntax users of Searchlogic are familiar with, association_classname_type.
56 | For example: commentable_article_type_contains
57 | * Join using left outer joins instead of inner joins. This lets you do
58 | some interesting things like search for all articles with no comments via
59 | comments_id_is_null.
60 | * No longer define method on the metaclass - stick to standard method_missing
61 | for both correctness and performance.
62 |
63 | Changes since 0.9.5 (2010-09-28):
64 | * Fix issue with formatters supplied as strings
65 |
66 | Changes since 0.9.4 (2010-09-18):
67 | * Rename check_boxes and collection_check_boxes to checks and
68 | collection_checks. Alias to the old names if not already taken. This
69 | is to avoid conflicts with SimpleForm.
70 |
71 | Changes since 0.9.3 (2010-09-08):
72 | * Minor documentation fixes.
73 | * Add sort_link helper to FormBuilder, to spare keystrokes if sort_links
74 | are being added inside the context of the form_for of the search.
75 |
76 | Changes since 0.9.2 (2010-08-25):
77 | * Update dependencies for Rails 3 final.
78 |
79 | Changes since 0.9.1 (2010-08-24):
80 | * Fix time column casts to account for current time zone.
81 |
82 | Changes since 0.9.0 (2010-08-24):
83 | * Fix the missing "2" in the Rails 3.0.0.rc2 dependency. Sorry!
84 |
85 | Changes since 0.5.4 (2010-07-28):
86 | * Fix equals Where against boolean columns
87 | * Add is_true/is_false for booleans, is_present/is_blank for other types
88 | * Add is_null/is_not_null for all types
89 | * Remove deprecated metasearch_exclude_attr and friends
90 | * delegate #size and #length to relation
91 |
92 | Changes since 0.5.3 (2010-07-26):
93 | * Add is_true/is_false for boolean columns
94 | * Add is_present and is_blank for string/numeric columns
95 | * Add is_null and is_not_null for all columns
96 | * Fix behavior of equals when used with boolean columns.
97 |
98 | Changes since 0.5.2 (2010-07-22):
99 | * Handle nested/namespaced form_for better. Formerly, you could use
100 | "form_for @search" in a view, but not "form_for [:admin, @search]"
101 |
102 | Changes since 0.5.1 (2010-07-20):
103 | * Fix fallback for failed cast via to_time or to_date
104 | * add :cast option for custom Wheres, allowing a where to override
105 | the default cast of the incoming parameters.
106 |
107 | Changes since 0.5.0 (2010-06-08):
108 | * Fix searching against relations derived from a has_many :through
109 | association
110 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source :rubygems
2 |
3 | # Don't do a "gemspec" here. Seriously. It jacks up Jeweler.
4 |
5 | gem "activerecord", "~> 3.1"
6 | gem "activesupport", "~> 3.1"
7 | gem "polyamorous", "~> 0.5.0"
8 | gem "actionpack", "~> 3.1"
9 |
10 | group :development do
11 | gem "shoulda", "~> 2.11"
12 | end
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2009 Ernie Miller
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/README.rdoc:
--------------------------------------------------------------------------------
1 | This project is archived
2 |
3 |
4 | = MetaSearch
5 |
6 | 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.
7 |
8 | == NOTE
9 |
10 | The successor to MetaSearch is {Ransack}[http://github.com/ernie/ransack]. It's got features
11 | that MetaSearch doesn't, along with some API changes. I haven't had the time to dedicate to
12 | making it bulletproof yet, so I'm releasing a 1.1.x branch of MetaSearch to help with migrations
13 | to Rails 3.1.
14 |
15 | This is intended to be a stopgap measure.
16 |
17 | t's important to note that the long-term migration path for your apps should be toward
18 | Ransack, which is written in a more sane manner that will make supporting new versions
19 | of Rails much easier going forward.
20 |
21 | == Getting Started
22 |
23 | In your Gemfile:
24 |
25 | gem "meta_search" # Last officially released gem
26 | # gem "meta_search", :git => "git://github.com/ernie/meta_search.git" # Track git repo
27 |
28 | or, to install as a plugin:
29 |
30 | rails plugin install git://github.com/ernie/meta_search.git
31 |
32 | In your controller:
33 |
34 | def index
35 | @search = Article.search(params[:search])
36 | @articles = @search.all # load all matching records
37 | # @articles = @search.relation # Retrieve the relation, to lazy-load in view
38 | # @articles = @search.paginate(:page => params[:page]) # Who doesn't love will_paginate?
39 | end
40 |
41 | In your view:
42 |
43 | <%= form_for @search, :url => articles_path, :html => {:method => :get} do |f| %>
44 | <%= f.label :title_contains %>
45 | <%= f.text_field :title_contains %>
46 | <%= f.label :comments_created_at_greater_than, 'With comments after' %>
47 | <%= f.datetime_select :comments_created_at_greater_than, :include_blank => true %>
48 |
49 | <%= f.submit %>
50 | <% end %>
51 |
52 | Options for the search method are documented at MetaSearch::Searches::ActiveRecord.
53 |
54 | == "Wheres", and what they're good for
55 |
56 | Wheres are how MetaSearch does its magic. Wheres have a name (and possible aliases) which are
57 | appended to your model and association attributes. When you instantiate a MetaSearch::Builder
58 | against a model (manually or by calling your model's +search+ method) the builder responds to
59 | methods named for your model's attributes and associations, suffixed by the name of the Where.
60 |
61 | These are the default Wheres, broken down by the types of ActiveRecord columns they can search
62 | against:
63 |
64 | === All data types
65 |
66 | * _equals_ (alias: _eq_) - Just as it sounds.
67 | * _does_not_equal_ (aliases: _ne_, _noteq_) - The opposite of equals, oddly enough.
68 | * _in_ - Takes an array, matches on equality with any of the items in the array.
69 | * _not_in_ (aliases: _ni_, _notin_) - Like above, but negated.
70 | * _is_null_ - The column has an SQL NULL value.
71 | * _is_not_null_ - The column contains anything but NULL.
72 |
73 | === Strings
74 |
75 | * _contains_ (aliases: _like_, _matches_) - Substring match.
76 | * _does_not_contain_ (aliases: _nlike_, _nmatches_) - Negative substring match.
77 | * _starts_with_ (alias: _sw_) - Match strings beginning with the entered term.
78 | * _does_not_start_with_ (alias: _dnsw_) - The opposite of above.
79 | * _ends_with_ (alias: _ew_) - Match strings ending with the entered term.
80 | * _does_not_end_with_ (alias: _dnew_) - Negative of above.
81 |
82 | === Numbers, dates, and times
83 |
84 | * _greater_than_ (alias: _gt_) - Greater than.
85 | * _greater_than_or_equal_to_ (aliases: _gte_, _gteq_) - Greater than or equal to.
86 | * _less_than_ (alias: _lt_) - Less than.
87 | * _less_than_or_equal_to_ (aliases: _lte_, _lteq_) - Less than or equal to.
88 |
89 | === Booleans
90 |
91 | * _is_true_ - Is true. Useful for a checkbox like "only show admin users".
92 | * _is_false_ - The complement of _is_true_.
93 |
94 | === Non-boolean data types
95 |
96 | * _is_present_ - As with _is_true_, useful with a checkbox. Not NULL or the empty string.
97 | * _is_blank_ - Returns records with a value of NULL or the empty string in the column.
98 |
99 | So, given a model like this...
100 |
101 | class Article < ActiveRecord::Base
102 | belongs_to :author
103 | has_many :comments
104 | has_many :moderations, :through => :comments
105 | end
106 |
107 | ...you might end up with attributes like title_contains,
108 | comments_title_starts_with, moderations_value_less_than,
109 | author_name_equals, and so on.
110 |
111 | Additionally, all of the above predicate types also have an _any and _all version, which
112 | expects an array of the corresponding parameter type, and requires any or all of the
113 | parameters to be a match, respectively. So:
114 |
115 | Article.search :author_name_starts_with_any => ['Jim', 'Bob', 'Fred']
116 |
117 | will match articles authored by Jimmy, Bobby, or Freddy, but not Winifred.
118 |
119 | == Advanced usage
120 |
121 | === Narrowing the scope of a search
122 |
123 | While the most common use case is to simply call Model.search(params[:search]), there
124 | may be times where you want to scope your search more tightly. For instance, only allowing
125 | users to search their own projects (assuming a current_user method returning the current user):
126 |
127 | @search = current_user.projects.search(params[:search])
128 |
129 | Or, you can build up any relation you like and call the search method on that object:
130 |
131 | @projects_with_awesome_users_search =
132 | Project.joins(:user).where(:users => {:awesome => true}).search(params[:search])
133 |
134 | === ORed conditions
135 |
136 | If you'd like to match on one of several possible columns, you can do this:
137 |
138 | <%= f.text_field :title_or_description_contains %>
139 | <%= f.text_field :title_or_author_name_starts_with %>
140 |
141 | Caveats:
142 |
143 | * Only one match type is supported. You can't do
144 | title_matches_or_description_starts_with for instance.
145 | * If you're matching across associations, remember that the associated table will be
146 | INNER JOINed, therefore limiting results to those that at least have a corresponding
147 | record in the associated table.
148 |
149 | === Compound conditions (any/all)
150 |
151 | All Where types automatically get an "any" and "all" variant. This has the same name and
152 | aliases as the original, but is suffixed with _any and _all, for an "OR" or "AND" search,
153 | respectively. So, if you want to provide the user with 5 different search boxes to enter
154 | possible article titles:
155 |
156 | <%= f.multiparameter_field :title_contains_any,
157 | *5.times.inject([]) {|a, b| a << {:field_type => :text_field}} +
158 | [:size => 10] %>
159 |
160 | === Multi-level associations
161 |
162 | MetaSearch will allow you to traverse your associations in one form, generating the
163 | necessary joins along the way. If you have the following models...
164 |
165 | class Company < ActiveRecord::Base
166 | has_many :developers
167 | end
168 |
169 | class Developer < ActiveRecord::Base
170 | belongs_to :company
171 | has_many :notes
172 | end
173 |
174 | ...you can do this in your form to search your companies by developers with certain notes:
175 |
176 | <%= f.text_field :developers_notes_note_contains %>
177 |
178 | You can travel forward and back through the associations, so this would also work (though
179 | be entirely pointless in this case):
180 |
181 | <%= f.text_field :developers_notes_developer_company_name_contains %>
182 |
183 | However, to prevent abuse, this is limited to associations of a total "depth" of 5 levels.
184 | This means that while starting from a Company model, as above, you could do
185 | Company -> :developers -> :notes -> :developer -> :company, which has gotten you right
186 | back where you started, but "travels" through 5 models total.
187 |
188 | In the case of polymorphic belongs_to associations, things work a bit differently. Let's say
189 | you have the following models:
190 |
191 | class Article < ActiveRecord::Base
192 | has_many :comments, :as => :commentable
193 | end
194 |
195 | class Post < ActiveRecord::Base
196 | has_many :comments, :as => :commentable
197 | end
198 |
199 | class Comment < ActiveRecord::Base
200 | belongs_to :commentable, :polymorphic => true
201 | validates_presence_of :body
202 | end
203 |
204 | Your first instinct might be to set up a text field for :commentable_body_contains, but
205 | you can't do this. MetaSearch would have no way to know which class lies on the other side
206 | of the polymorphic association, so it wouldn't be able to join the correct tables.
207 |
208 | Instead, you'll follow a convention Searchlogic users are already familiar with, using the
209 | name of the polymorphic association, then the underscored class name (AwesomeClass becomes
210 | awesome_class), then the delimiter "type", to tell MetaSearch anything that follows is an
211 | attribute name. For example:
212 |
213 | <%= f.text_field :commentable_article_type_body_contains %>
214 |
215 | If you'd like to match on multiple types of polymorphic associations, you can join them
216 | with \_or_, just like any other conditions:
217 |
218 | <%= f.text_field :commentable_article_type_body_or_commentable_post_type_body_contains %>
219 |
220 | It's not pretty, but it works. Alternately, consider creating a custom search method as
221 | described below to save yourself some typing if you're creating a lot of these types of
222 | search fields.
223 |
224 | === Adding a new Where
225 |
226 | If none of the built-in search criteria work for you, you can add new Wheres. To do so,
227 | create an initializer (/config/initializers/meta_search.rb, for instance) and add lines
228 | like:
229 |
230 | MetaSearch::Where.add :between, :btw,
231 | :predicate => :in,
232 | :types => [:integer, :float, :decimal, :date, :datetime, :timestamp, :time],
233 | :formatter => Proc.new {|param| Range.new(param.first, param.last)},
234 | :validator => Proc.new {|param|
235 | param.is_a?(Array) && !(param[0].blank? || param[1].blank?)
236 | }
237 |
238 | See MetaSearch::Where for info on the supported options.
239 |
240 | === Accessing custom search methods (and named scopes!)
241 |
242 | MetaSearch can be given access to any class method on your model to extend its search capabilities.
243 | The only rule is that the method must return an ActiveRecord::Relation so that MetaSearch can
244 | continue to extend the search with other attributes. Conveniently, scopes (formerly "named scopes")
245 | do this already.
246 |
247 | Consider the following model:
248 |
249 | class Company < ActiveRecord::Base
250 | has_many :slackers, :class_name => "Developer", :conditions => {:slacker => true}
251 | scope :backwards_name, lambda {|name| where(:name => name.reverse)}
252 | scope :with_slackers_by_name_and_salary_range,
253 | lambda {|name, low, high|
254 | joins(:slackers).where(:developers => {:name => name, :salary => low..high})
255 | }
256 | end
257 |
258 | To allow MetaSearch access to a model method, including a named scope, just use
259 | search_methods in the model:
260 |
261 | search_methods :backwards_name
262 |
263 | This will allow you to add a text field named :backwards_name to your search form, and
264 | it will behave as you might expect.
265 |
266 | In the case of the second scope, we have multiple parameters to pass in, of different
267 | types. We can pass the following to search_methods:
268 |
269 | search_methods :with_slackers_by_name_and_salary_range,
270 | :splat_param => true, :type => [:string, :integer, :integer]
271 |
272 | MetaSearch needs us to tell it that we don't want to keep the array supplied to it as-is, but
273 | "splat" it when passing it to the model method. Regarding :types: In this case,
274 | ActiveRecord would have been smart enough to handle the typecasting for us, but I wanted to
275 | 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
276 | on the DB column being searched. It's also important so that things like dates get handled
277 | properly by FormBuilder.
278 |
279 | === multiparameter_field
280 |
281 | The example Where above adds support for a "between" search, which requires an array with
282 | two parameters. These can be passed using Rails multiparameter attributes. To make life easier,
283 | MetaSearch adds a helper for this:
284 |
285 | <%= f.multiparameter_field :moderations_value_between,
286 | {:field_type => :text_field}, {:field_type => :text_field}, :size => 5 %>
287 |
288 | multiparameter_field works pretty much like the other FormBuilder helpers, but it
289 | lets you sandwich a list of fields, each in hash format, between the attribute and the usual
290 | options hash. See MetaSearch::Helpers::FormBuilder for more info.
291 |
292 | === checks and collection_checks
293 |
294 | If you need to get an array into your where, and you don't care about parameter order,
295 | you might choose to use a select or collection_select with multiple selection enabled,
296 | but everyone hates multiple selection boxes. MetaSearch adds a couple of additional
297 | helpers, +checks+ and +collection_checks+ to handle multiple selections in a
298 | more visually appealing manner. They can be called with or without a block. Without a
299 | block, you get an array of MetaSearch::Check objects to do with as you please.
300 |
301 | With a block, each check is yielded to your template, like so:
302 |
303 |
313 |
314 | Again, full documentation is in MetaSearch::Helpers::FormBuilder.
315 |
316 | === Sorting columns
317 |
318 | If you'd like to sort by a specific column in your results (the attributes of the base model)
319 | or an association column then supply the meta_sort parameter in your form.
320 | The parameter takes the form column.direction where +column+ is the column name or
321 | underscore-separated association_column combination, and +direction+ is one of "asc" or "desc"
322 | for ascending or descending, respectively.
323 |
324 | Normally, you won't supply this parameter yourself, but instead will use the helper method
325 | sort_link in your views, like so:
326 |
327 | <%= sort_link @search, :title %>
328 |
329 | Or, if in the context of a form_for against a MetaSearch::Builder:
330 |
331 | <%= f.sort_link :title %>
332 |
333 | The @search object is the instance of MetaSearch::Builder you got back earlier from
334 | your controller. The other required parameter is the attribute name itself. Optionally,
335 | you can provide a string as a 3rd parameter to override the default link name, and then
336 | additional hashed for the +options+ and +html_options+ hashes for link_to.
337 |
338 | By default, the link that is created will sort by the given column in ascending order when first clicked. If you'd like to reverse this (so the first click sorts the results in descending order), you can pass +:default_order => :desc+ in the options hash, like so:
339 |
340 | <%= sort_link @search, :ratings, "Highest Rated", :default_order => :desc %>
341 |
342 | You can sort by more than one column as well, by creating a link like:
343 |
344 | <%= sort_link :name_and_salary %>
345 |
346 | If you'd like to do a custom sort, you can do so by setting up two scopes in your model:
347 |
348 | scope :sort_by_custom_name_asc, order('custom_name ASC')
349 | scope :sort_by_custom_name_desc, order('custom_name DESC')
350 |
351 | You can then do sort_link @search, :custom_name and it will work as you expect.
352 |
353 | All sort_link-generated links will have the CSS class sort_link, as well as a
354 | directional class (ascending or descending) if the link is for a currently sorted column,
355 | for your styling enjoyment.
356 |
357 | This feature should hopefully help out those of you migrating from Searchlogic, and a thanks
358 | goes out to Ben Johnson for the HTML entities used for the up and down arrows, which provide
359 | a nice default look.
360 |
361 | === Including/excluding attributes and associations
362 |
363 | If you'd like to allow only certain associations or attributes to be searched, you can do
364 | so inside your models
365 |
366 | class Article < ActiveRecord::Base
367 | attr_searchable :some_public_data, :some_more_searchable_stuff
368 | assoc_searchable :search_this_association_why_dontcha
369 | end
370 |
371 | If you'd rather blacklist attributes and associations rather than whitelist, use the
372 | attr_unsearchable and assoc_unsearchable method instead. If a
373 | whitelist is supplied, it takes precedence.
374 |
375 | Excluded attributes on a model will be honored across associations, so if an Article
376 | has_many :comments and the Comment model looks something like this:
377 |
378 | class Comment < ActiveRecord::Base
379 | validates_presence_of :user_id, :body
380 | attr_unsearchable :user_id
381 | end
382 |
383 | Then your call to Article.search will allow :comments_body_contains
384 | but not :comments_user_id_equals to be passed.
385 |
386 | === Conditional access to searches
387 |
388 | search_methods, attr_searchable, attr_unsearchable,
389 | assoc_searchable, and assoc_unsearchable all accept an :if
390 | option. If present, it should specify a Proc (or other object responding to call)
391 | that accepts a single parameter. This parameter will be the instance of the MetaSearch::Builder
392 | that gets created by a call to Model.search. Any unused search options (the second hash param)
393 | that get passed to Model.search will be available via the Builder object's options
394 | reader, and can be used for access control via this proc/object.
395 |
396 | Example:
397 |
398 | assoc_unsearchable :notes,
399 | :if => proc {|s| s.options[:access] == 'blocked' || !s.options[:access]}
400 |
401 | === Localization
402 |
403 | MetaSearch supports i18n localization in a few different ways. Consider this abbreviated
404 | example "flanders" locale:
405 |
406 | flanders:
407 | activerecord:
408 | attributes:
409 | company:
410 | name: "Company name-diddly"
411 | developer:
412 | name: "Developer name-diddly"
413 | salary: "Developer salary-doodly"
414 | meta_search:
415 | or: 'or-diddly'
416 | predicates:
417 | contains: "%{attribute} contains-diddly"
418 | equals: "%{attribute} equals-diddly"
419 | attributes:
420 | company:
421 | reverse_name: "Company reverse name-diddly"
422 | developer:
423 | name_contains: "Developer name-diddly contains-aroonie"
424 |
425 | First, MetaSearch will use a key found under meta_search.attributes.model_name.attribute_name,
426 | if it exists. As a fallback, it will use a localization based on the predicate type, along with
427 | the usual ActiveRecord attribute localization (the activerecord.attributes.model_name keys above).
428 | Additionally, a localized "or" can be specified for multi-column searches.
429 |
430 | == Contributions
431 |
432 | There are several ways you can help MetaSearch continue to improve.
433 |
434 | * Use MetaSearch in your real-world projects and {submit bug reports or feature suggestions}[http://metautonomous.lighthouseapp.com/projects/53012-metasearch/].
435 | * Better yet, if you’re so inclined, fix the issue yourself and submit a patch! Or you can {fork the project on GitHub}[http://github.com/ernie/meta_search] and send me a pull request (please include tests!)
436 | * If you like MetaSearch, spread the word. More users == more eyes on code == more bugs getting found == more bugs getting fixed (hopefully!)
437 | * Lastly, if MetaSearch has saved you hours of development time on your latest Rails gig, and you’re feeling magnanimous, please consider {making a donation}[http://pledgie.com/campaigns/9647] to the project. I have spent hours of my personal time coding and supporting MetaSearch, and your donation would go a great way toward justifying that time spent to my loving wife. :)
438 |
439 | == Copyright
440 |
441 | Copyright (c) 2010 {Ernie Miller}[http://metautonomo.us]. See LICENSE for details.
442 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require 'rubygems'
2 | require 'rake'
3 |
4 | begin
5 | require 'jeweler'
6 | Jeweler::Tasks.new do |gem|
7 | gem.name = "meta_search"
8 | gem.summary = %Q{Object-based searching (and more) for simply creating search forms.}
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 | }
14 | gem.email = "ernie@metautonomo.us"
15 | gem.homepage = "http://metautonomo.us/projects/metasearch/"
16 | gem.authors = ["Ernie Miller"]
17 | gem.post_install_message = < :check_dependencies
58 |
59 | task :default => :test
60 |
61 | require 'rdoc/task'
62 | Rake::RDocTask.new do |rdoc|
63 | version = File.exist?('VERSION') ? File.read('VERSION') : ""
64 |
65 | rdoc.rdoc_dir = 'rdoc'
66 | rdoc.title = "meta_search #{version}"
67 | rdoc.rdoc_files.include('README*')
68 | rdoc.rdoc_files.include('lib/**/*.rb')
69 | end
70 |
--------------------------------------------------------------------------------
/VERSION:
--------------------------------------------------------------------------------
1 | 1.1.3
--------------------------------------------------------------------------------
/lib/meta_search.rb:
--------------------------------------------------------------------------------
1 | module MetaSearch
2 | NUMBERS = [:integer, :float, :decimal]
3 | STRINGS = [:string, :text, :binary]
4 | DATES = [:date]
5 | TIMES = [:datetime, :timestamp, :time]
6 | BOOLEANS = [:boolean]
7 | ALL_TYPES = NUMBERS + STRINGS + DATES + TIMES + BOOLEANS
8 |
9 | # Change this only if you know what you're doing. It's here for your protection.
10 | MAX_JOIN_DEPTH = 5
11 |
12 | DEFAULT_WHERES = [
13 | ['equals', 'eq', {:validator => Proc.new {|param| !param.blank? || (param == false)}}],
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 => :does_not_match, :formatter => '"%#{param}%"'}],
17 | ['starts_with', 'sw', {:types => STRINGS, :predicate => :matches, :formatter => '"#{param}%"'}],
18 | ['does_not_start_with', 'dnsw', {:types => STRINGS, :predicate => :does_not_match, :formatter => '"#{param}%"'}],
19 | ['ends_with', 'ew', {:types => STRINGS, :predicate => :matches, :formatter => '"%#{param}"'}],
20 | ['does_not_end_with', 'dnew', {:types => STRINGS, :predicate => :does_not_match, :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}],
27 | ['is_true', {:types => BOOLEANS, :skip_compounds => true}],
28 | ['is_false', {:types => BOOLEANS, :skip_compounds => true, :formatter => Proc.new {|param| !param}}],
29 | ['is_present', {:types => (ALL_TYPES - BOOLEANS), :predicate => :not_eq_all, :skip_compounds => true, :cast => :boolean, :formatter => Proc.new {|param| [nil, '']}}],
30 | ['is_blank', {:types => (ALL_TYPES - BOOLEANS), :predicate => :eq_any, :skip_compounds => true, :cast => :boolean, :formatter => Proc.new {|param| [nil, '']}}],
31 | ['is_null', {:types => ALL_TYPES, :skip_compounds => true, :cast => :boolean, :formatter => Proc.new {|param| nil}}],
32 | ['is_not_null', {:types => ALL_TYPES, :predicate => :not_eq, :skip_compounds => true, :cast => :boolean, :formatter => Proc.new {|param| nil}}]
33 | ]
34 |
35 | RELATION_METHODS = [
36 | # Query construction
37 | :joins, :includes, :select, :order, :where, :having, :group,
38 | # Results, debug, array methods
39 | :to_a, :all, :length, :size, :to_sql, :debug_sql, :paginate, :page,
40 | :find_each, :first, :last, :each, :arel, :in_groups_of, :group_by,
41 | # Calculations
42 | :count, :average, :minimum, :maximum, :sum
43 | ]
44 | end
45 |
46 | require 'active_record'
47 | require 'active_support'
48 | require 'action_view'
49 | require 'action_controller'
50 | require 'meta_search/searches/active_record'
51 | require 'meta_search/helpers'
52 |
53 | I18n.load_path += Dir[File.join(File.dirname(__FILE__), 'meta_search', 'locale', '*.yml')]
54 |
55 | ActiveRecord::Base.send(:include, MetaSearch::Searches::ActiveRecord)
56 | ActionView::Helpers::FormBuilder.send(:include, MetaSearch::Helpers::FormBuilder)
57 | ActionController::Base.helper(MetaSearch::Helpers::UrlHelper)
58 | ActionController::Base.helper(MetaSearch::Helpers::FormHelper)
--------------------------------------------------------------------------------
/lib/meta_search/builder.rb:
--------------------------------------------------------------------------------
1 | require 'polyamorous'
2 | require 'meta_search/model_compatibility'
3 | require 'meta_search/exceptions'
4 | require 'meta_search/where'
5 | require 'meta_search/utility'
6 |
7 | module MetaSearch
8 | # Builder is the workhorse of MetaSearch -- it is the class that handles dynamically generating
9 | # methods based on a supplied model, and is what gets instantiated when you call your model's search
10 | # method. Builder doesn't generate any methods until they're needed, using method_missing to compare
11 | # requested method names against your model's attributes, associations, and the configured Where
12 | # list.
13 | #
14 | # === Attributes
15 | #
16 | # * +base+ - The base model that Builder wraps.
17 | # * +search_attributes+ - Attributes that have been assigned (search terms)
18 | # * +relation+ - The ActiveRecord::Relation representing the current search.
19 | # * +join_dependency+ - The JoinDependency object representing current association join
20 | # dependencies. It's used internally to avoid joining association tables more than
21 | # once when constructing search queries.
22 | class Builder
23 | include ModelCompatibility
24 | include Utility
25 |
26 | attr_reader :base, :relation, :search_key, :search_attributes, :join_dependency, :errors, :options
27 | delegate *RELATION_METHODS + [:to => :relation]
28 |
29 | # Initialize a new Builder. Requires a base model to wrap, and supports a couple of options
30 | # for how it will expose this model and its associations to your controllers/views.
31 | def initialize(base_or_relation, opts = {})
32 | opts = opts.dup
33 | @relation = base_or_relation.scoped
34 | @base = @relation.klass
35 | @search_key = (opts.delete(:search_key) || 'search').to_s
36 | @options = opts # Let's just hang on to other options for use in authorization blocks
37 | @join_type = opts[:join_type] || Arel::Nodes::OuterJoin
38 | @join_type = get_join_type(@join_type)
39 | @join_dependency = build_join_dependency(@relation)
40 | @search_attributes = {}
41 | @errors = ActiveModel::Errors.new(self)
42 | end
43 |
44 | def get_column(column, base = @base)
45 | base.columns_hash[column.to_s] if base._metasearch_attribute_authorized?(column, self)
46 | end
47 |
48 | def get_association(assoc, base = @base)
49 | base.reflect_on_association(assoc.to_sym) if base._metasearch_association_authorized?(assoc, self)
50 | end
51 |
52 | def get_attribute(name, parent = @join_dependency.join_base)
53 | attribute = nil
54 | if get_column(name, parent.active_record)
55 | attribute = parent.table[name]
56 | elsif (segments = name.to_s.split(/_/)).size > 1
57 | remainder = []
58 | found_assoc = nil
59 | while remainder.unshift(segments.pop) && segments.size > 0 && !found_assoc do
60 | if found_assoc = get_association(segments.join('_'), parent.active_record)
61 | if found_assoc.options[:polymorphic]
62 | unless delimiter = remainder.index('type')
63 | raise PolymorphicAssociationMissingTypeError, "Polymorphic association specified without a type"
64 | end
65 | polymorphic_class, attribute_name = remainder[0...delimiter].join('_'),
66 | remainder[delimiter + 1...remainder.size].join('_')
67 | polymorphic_class = polymorphic_class.classify.constantize
68 | join = build_or_find_association(found_assoc.name, parent, polymorphic_class)
69 | attribute = get_attribute(attribute_name, join)
70 | else
71 | join = build_or_find_association(found_assoc.name, parent, found_assoc.klass)
72 | attribute = get_attribute(remainder.join('_'), join)
73 | end
74 | end
75 | end
76 | end
77 | attribute
78 | end
79 |
80 | # Build the search with the given search options. Options are in the form of a hash
81 | # with keys matching the names creted by the Builder's "wheres" as outlined in
82 | # MetaSearch::Where
83 | def build(option_hash)
84 | opts = option_hash.dup || {}
85 | @relation = @base.scoped
86 | opts.stringify_keys!
87 | opts = collapse_multiparameter_options(opts)
88 | assign_attributes(opts)
89 | self
90 | end
91 |
92 | def respond_to?(method_id, include_private = false)
93 | return true if super
94 |
95 | method_name = method_id.to_s
96 | if RELATION_METHODS.map(&:to_s).include?(method_name)
97 | true
98 | elsif method_name.match(/^meta_sort=?$/)
99 | true
100 | elsif match = method_name.match(/^(.*)\(([0-9]+).*\)$/)
101 | method_name, index = match.captures
102 | respond_to?(method_name)
103 | elsif matches_named_method(method_name) || matches_attribute_method(method_name)
104 | true
105 | else
106 | false
107 | end
108 | end
109 |
110 | private
111 |
112 | def assign_attributes(opts)
113 | opts.each_pair do |k, v|
114 | self.send("#{k}=", v)
115 | end
116 | end
117 |
118 | def gauge_depth_of_join_association(ja)
119 | 1 + (ja.respond_to?(:parent) ? gauge_depth_of_join_association(ja.parent) : 0)
120 | end
121 |
122 | def method_missing(method_id, *args, &block)
123 | method_name = method_id.to_s
124 | if method_name =~ /^meta_sort=?$/
125 | (args.any? || method_name =~ /=$/) ? set_sort(args.first) : get_sort
126 | elsif match = method_name.match(/^(.*)\(([0-9]+).*\)$/) # Multiparameter reader
127 | method_name, index = match.captures
128 | vals = self.send(method_name)
129 | vals.is_a?(Array) ? vals[index.to_i - 1] : nil
130 | elsif match = matches_named_method(method_name)
131 | (args.any? || method_name =~ /=$/) ? set_named_method_value(match, args.first) : get_named_method_value(match)
132 | elsif match = matches_attribute_method(method_id)
133 | attribute, predicate = match.captures
134 | (args.any? || method_name =~ /=$/) ? set_attribute_method_value(attribute, predicate, args.first) : get_attribute_method_value(attribute, predicate)
135 | else
136 | super
137 | end
138 | end
139 |
140 | def matches_named_method(name)
141 | method_name = name.to_s.sub(/\=$/, '')
142 | return method_name if @base._metasearch_method_authorized?(method_name, self)
143 | end
144 |
145 | def matches_attribute_method(method_id)
146 | method_name = preferred_method_name(method_id)
147 | where = Where.new(method_id) rescue nil
148 | return nil unless method_name && where
149 | match = method_name.match("^(.*)_(#{where.name})=?$")
150 | attribute, predicate = match.captures
151 | attributes = attribute.split(/_or_/)
152 | if attributes.all? {|a| where.types.include?(column_type(a))}
153 | return match
154 | end
155 | nil
156 | end
157 |
158 | def get_sort
159 | search_attributes['meta_sort']
160 | end
161 |
162 | def set_sort(val)
163 | return if val.blank?
164 | column, direction = val.split('.')
165 | direction ||= 'asc'
166 | if ['asc','desc'].include?(direction)
167 | if @base.respond_to?("sort_by_#{column}_#{direction}")
168 | search_attributes['meta_sort'] = val
169 | @relation = @relation.send("sort_by_#{column}_#{direction}")
170 | elsif attribute = get_attribute(column)
171 | search_attributes['meta_sort'] = val
172 | @relation = @relation.order(attribute.send(direction).to_sql)
173 | elsif column.scan('_and_').present?
174 | attribute_names = column.split('_and_')
175 | attributes = attribute_names.map {|n| get_attribute(n)}
176 | if attribute_names.size == attributes.compact.size # We found all attributes
177 | search_attributes['meta_sort'] = val
178 | attributes.each do |attribute|
179 | @relation = @relation.order(attribute.send(direction).to_sql)
180 | end
181 | end
182 | end
183 | end
184 | end
185 |
186 | def get_named_method_value(name)
187 | search_attributes[name]
188 | end
189 |
190 | def set_named_method_value(name, val)
191 | meth = @base._metasearch_methods[name][:method]
192 | search_attributes[name] = meth.cast_param(val)
193 | if meth.validate(search_attributes[name])
194 | return_value = meth.evaluate(@relation, search_attributes[name])
195 | if return_value.is_a?(ActiveRecord::Relation)
196 | @relation = return_value
197 | else
198 | raise NonRelationReturnedError, "Custom search methods must return an ActiveRecord::Relation. #{name} returned a #{return_value.class}"
199 | end
200 | end
201 | end
202 |
203 | def get_attribute_method_value(attribute, predicate)
204 | search_attributes["#{attribute}_#{predicate}"]
205 | end
206 |
207 | def set_attribute_method_value(attribute, predicate, val)
208 | where = Where.new(predicate)
209 | attributes = attribute.split(/_or_/)
210 | search_attributes["#{attribute}_#{predicate}"] = cast_attributes(where.cast || column_type(attributes.first), val)
211 | if where.validate(search_attributes["#{attribute}_#{predicate}"])
212 | arel_attributes = attributes.map {|a| get_attribute(a)}
213 | @relation = where.evaluate(@relation, arel_attributes, search_attributes["#{attribute}_#{predicate}"])
214 | end
215 | end
216 |
217 | def column_type(name, base = @base, depth = 1)
218 | type = nil
219 | if column = get_column(name, base)
220 | type = column.type
221 | elsif (segments = name.split(/_/)).size > 1
222 | type = type_from_association_segments(segments, base, depth)
223 | end
224 | type
225 | end
226 |
227 | def type_from_association_segments(segments, base, depth)
228 | remainder = []
229 | found_assoc = nil
230 | type = nil
231 | while remainder.unshift(segments.pop) && segments.size > 0 && !found_assoc do
232 | if found_assoc = get_association(segments.join('_'), base)
233 | depth += 1
234 | raise JoinDepthError, "Maximum join depth of #{MAX_JOIN_DEPTH} exceeded." if depth > MAX_JOIN_DEPTH
235 | if found_assoc.options[:polymorphic]
236 | unless delimiter = remainder.index('type')
237 | raise PolymorphicAssociationMissingTypeError, "Polymorphic association specified without a type"
238 | end
239 | polymorphic_class, attribute_name = remainder[0...delimiter].join('_'),
240 | remainder[delimiter + 1...remainder.size].join('_')
241 | polymorphic_class = polymorphic_class.classify.constantize
242 | type = column_type(attribute_name, polymorphic_class, depth)
243 | else
244 | type = column_type(remainder.join('_'), found_assoc.klass, depth)
245 | end
246 | end
247 | end
248 | type
249 | end
250 |
251 | def build_or_find_association(name, parent = @join_dependency.join_base, klass = nil)
252 | found_association = @join_dependency.join_associations.detect do |assoc|
253 | assoc.reflection.name == name &&
254 | assoc.parent == parent &&
255 | (!klass || assoc.reflection.klass == klass)
256 | end
257 | unless found_association
258 | @join_dependency.send(:build, Polyamorous::Join.new(name, @join_type, klass), parent)
259 | found_association = @join_dependency.join_associations.last
260 | # Leverage the stashed association functionality in AR
261 | @relation = @relation.joins(found_association)
262 | end
263 |
264 | found_association
265 | end
266 |
267 | def build_join_dependency(relation)
268 | buckets = relation.joins_values.group_by do |join|
269 | case join
270 | when String
271 | 'string_join'
272 | when Hash, Symbol, Array
273 | 'association_join'
274 | when ::ActiveRecord::Associations::JoinDependency::JoinAssociation
275 | 'stashed_join'
276 | when Arel::Nodes::Join
277 | 'join_node'
278 | else
279 | raise 'unknown class: %s' % join.class.name
280 | end
281 | end
282 |
283 | association_joins = buckets['association_join'] || []
284 | stashed_association_joins = buckets['stashed_join'] || []
285 | join_nodes = buckets['join_node'] || []
286 | string_joins = (buckets['string_join'] || []).map { |x|
287 | x.strip
288 | }.uniq
289 |
290 | join_list = relation.send :custom_join_ast, relation.table.from(relation.table), string_joins
291 |
292 | join_dependency = ::ActiveRecord::Associations::JoinDependency.new(
293 | relation.klass,
294 | association_joins,
295 | join_list
296 | )
297 |
298 | join_nodes.each do |join|
299 | join_dependency.alias_tracker.aliased_name_for(join.left.name.downcase)
300 | end
301 |
302 | join_dependency.graft(*stashed_association_joins)
303 | end
304 |
305 | def get_join_type(opt_join)
306 | # Allow "inner"/:inner and "upper"/:upper
307 | if opt_join.to_s.upcase == 'INNER'
308 | opt_join = Arel::Nodes::InnerJoin
309 | elsif opt_join.to_s.upcase == 'OUTER'
310 | opt_join = Arel::Nodes::OuterJoin
311 | end
312 | # Default to trusting what the user gave us
313 | opt_join
314 | end
315 | end
316 | end
317 |
--------------------------------------------------------------------------------
/lib/meta_search/exceptions.rb:
--------------------------------------------------------------------------------
1 | module MetaSearch
2 | # Raised when type casting for a column fails.
3 | class TypeCastError < StandardError; end
4 |
5 | # Raised if you don't return a relation from a custom search method.
6 | class NonRelationReturnedError < StandardError; end
7 |
8 | # Raised if you try to access a relation that's joining too many tables to itself.
9 | # This is designed to prevent a malicious user from accessing something like
10 | # :developers_company_developers_company_developers_company_developers_company_...,
11 | # resulting in a query that could cause issues for your database server.
12 | class JoinDepthError < StandardError; end
13 |
14 | # Raised if you try to search on a polymorphic belongs_to association without specifying
15 | # its type.
16 | class PolymorphicAssociationMissingTypeError < StandardError; end
17 | end
--------------------------------------------------------------------------------
/lib/meta_search/helpers.rb:
--------------------------------------------------------------------------------
1 | require 'meta_search/helpers/form_builder'
2 | require 'meta_search/helpers/form_helper'
3 | require 'meta_search/helpers/url_helper'
--------------------------------------------------------------------------------
/lib/meta_search/helpers/form_builder.rb:
--------------------------------------------------------------------------------
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 |
9 | def self.included(base)
10 | # Only take on the check_boxes method names if someone else (Hi, José!) hasn't grabbed them.
11 | alias_method :check_boxes, :checks unless base.method_defined?(:check_boxes)
12 | alias_method :collection_check_boxes, :collection_checks unless base.method_defined?(:collection_check_boxes)
13 | end
14 |
15 | # Like other form_for field methods (text_field, hidden_field, password_field) etc,
16 | # but takes a list of hashes between the +method+ parameter and the trailing option hash,
17 | # if any, to specify a number of fields to create in multiparameter fashion.
18 | #
19 | # Each hash *must* contain a :field_type option, which specifies a form_for method, and
20 | # _may_ contain an optional :type_cast option, with one of the typical multiparameter
21 | # type cast characters. Any remaining options will be merged with the defaults specified
22 | # in the trailing option hash and passed along when creating that field.
23 | #
24 | # For example...
25 | #
26 | # <%= f.multiparameter_field :moderations_value_between,
27 | # {:field_type => :text_field, :class => 'first'},
28 | # {:field_type => :text_field, :type_cast => 'i'},
29 | # :size => 5 %>
30 | #
31 | # ...will create the following HTML:
32 | #
33 | #
35 | #
36 | #
38 | #
39 | # As with any multiparameter input fields, these will be concatenated into an
40 | # array and passed to the attribute named by the first parameter for assignment.
41 | def multiparameter_field(method, *args)
42 | defaults = has_multiparameter_defaults?(args) ? args.pop : {}
43 | raise ArgumentError, "No multiparameter fields specified" if args.blank?
44 | html = ''.html_safe
45 | args.each_with_index do |field, index|
46 | type = field.delete(:field_type) || raise(ArgumentError, "No :field_type specified.")
47 | cast = field.delete(:type_cast) || ''
48 | opts = defaults.merge(field)
49 | html.safe_concat(
50 | @template.send(
51 | type.to_s,
52 | @object_name,
53 | (method.to_s + "(#{index + 1}#{cast})"),
54 | objectify_options(opts))
55 | )
56 | end
57 | html
58 | end
59 |
60 | # Behaves almost exactly like the select method, but instead of generating a select tag,
61 | # generates MetaSearch::Checks. These consist of two attributes, +box+ and +label+,
62 | # which are (unsurprisingly) the HTML for the check box and the label. Called without a block,
63 | # this method will return an array of check boxes. Called with a block, it will yield each
64 | # check box to your template.
65 | #
66 | # *Parameters:*
67 | #
68 | # * +method+ - The method name on the form_for object
69 | # * +choices+ - An array of arrays, the first value in each element is the text for the
70 | # label, and the last is the value for the checkbox
71 | # * +options+ - An options hash to be passed through to the checkboxes
72 | #
73 | # *Examples:*
74 | #
75 | # Simple formatting:
76 | #
77 | #
87 | #
88 | # This example will output the checkboxes and labels in an unordered list format.
89 | #
90 | # Grouping:
91 | #
92 | # Chain in_groups_of(, false) on checks like so:
93 | #
105 | def checks(method, choices = [], options = {}, &block)
106 | unless choices.first.respond_to?(:first) && choices.first.respond_to?(:last)
107 | raise ArgumentError, 'invalid choice array specified'
108 | end
109 | collection_checks(method, choices, :last, :first, options, &block)
110 | end
111 |
112 | # Just like +checks+, but this time you can pass in a collection, value, and text method,
113 | # as with collection_select.
114 | #
115 | # Example:
116 | #
117 | # <% f.collection_checks :head_sizes_in, HeadSize.all,
118 | # :id, :name, :class => 'headcheck' do |check| %>
119 | # <%= check.box %> <%= check.label %>
120 | # <% end %>
121 | def collection_checks(method, collection, value_method, text_method, options = {}, &block)
122 | check_boxes = []
123 | collection.each do |choice|
124 | text = choice.send(text_method)
125 | value = choice.send(value_method)
126 | check = MetaSearch::Check.new
127 | check.box = @template.check_box_tag(
128 | "#{@object_name}[#{method}][]",
129 | value,
130 | [@object.send(method)].flatten.include?(value),
131 | options.merge(:id => [@object_name, method.to_s, value.to_s.underscore].join('_'))
132 | )
133 | check.label = @template.label_tag([@object_name, method.to_s, value.to_s.underscore].join('_'),
134 | text)
135 | if block_given?
136 | yield check
137 | else
138 | check_boxes << check
139 | end
140 | end
141 | check_boxes unless block_given?
142 | end
143 |
144 | # Creates a sort link for the MetaSearch::Builder the form is created against.
145 | # Useful shorthand if your results happen to reside in the context of your
146 | # form_for block.
147 | # Sample usage:
148 | #
149 | # <%= f.sort_link :name %>
150 | # <%= f.sort_link :name, 'Company Name' %>
151 | # <%= f.sort_link :name, :class => 'name_sort' %>
152 | # <%= f.sort_link :name, 'Company Name', :class => 'company_name_sort' %>
153 | def sort_link(attribute, *args)
154 | @template.sort_link @object, attribute, *args
155 | end
156 |
157 | private
158 |
159 | # If the last element of the arguments to multiparameter_field has no :field_type
160 | # key, we assume it's got some defaults to be used in the other hashes.
161 | def has_multiparameter_defaults?(args)
162 | args.size > 1 && args.last.is_a?(Hash) && !args.last.has_key?(:field_type)
163 | end
164 | end
165 | end
166 | end
--------------------------------------------------------------------------------
/lib/meta_search/helpers/form_helper.rb:
--------------------------------------------------------------------------------
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?(MetaSearch::Builder)
6 | builder = object_or_array
7 | options[:url] ||= polymorphic_path(object_or_array.base)
8 | elsif object_or_array.is_a?(Array) && (builder = object_or_array.detect {|o| o.is_a?(MetaSearch::Builder)})
9 | options[:url] ||= polymorphic_path(object_or_array.map {|o| o.is_a?(MetaSearch::Builder) ? o.base : o})
10 | else
11 | super
12 | return
13 | end
14 |
15 | html_options = {
16 | :class => options[:as] ? "#{options[:as]}_search" : "#{builder.base.to_s.underscore}_search",
17 | :id => options[:as] ? "#{options[:as]}_search" : "#{builder.base.to_s.underscore}_search",
18 | :method => :get }
19 | options[:html] ||= {}
20 | options[:html].reverse_merge!(html_options)
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/meta_search/helpers/url_helper.rb:
--------------------------------------------------------------------------------
1 | module MetaSearch
2 | module Helpers
3 | module UrlHelper
4 |
5 | # Generates a column sort link for a given attribute of a MetaSearch::Builder object.
6 | # The link maintains existing options for the sort as parameters in the URL, and
7 | # sets a meta_sort parameter as well. If the first parameter after the attribute name
8 | # is not a hash, it will be used as a string for alternate link text. If a hash is
9 | # supplied, it will be passed to link_to as an html_options hash. The link will
10 | # be assigned two css classes: sort_link and one of "asc" or "desc", depending on
11 | # the current sort order. Any class supplied in the options hash will be appended.
12 | #
13 | # Sample usage:
14 | #
15 | # <%= sort_link @search, :name %>
16 | # <%= sort_link @search, :name, 'Company Name' %>
17 | # <%= sort_link @search, :name, :class => 'name_sort' %>
18 | # <%= sort_link @search, :name, 'Company Name', :class => 'company_name_sort' %>
19 | # <%= sort_link @search, :name, :default_order => :desc %>
20 | # <%= sort_link @search, :name, 'Company Name', :default_order => :desc %>
21 | # <%= sort_link @search, :name, :class => 'name_sort', :default_order => :desc %>
22 | # <%= sort_link @search, :name, 'Company Name', :class => 'company_name_sort', :default_order => :desc %>
23 |
24 | def sort_link(builder, attribute, *args)
25 | raise ArgumentError, "Need a MetaSearch::Builder search object as first param!" unless builder.is_a?(MetaSearch::Builder)
26 | attr_name = attribute.to_s
27 | name = (args.size > 0 && !args.first.is_a?(Hash)) ? args.shift.to_s : builder.base.human_attribute_name(attr_name)
28 | prev_attr, prev_order = builder.search_attributes['meta_sort'].to_s.split('.')
29 |
30 | options = args.first.is_a?(Hash) ? args.shift.dup : {}
31 | current_order = prev_attr == attr_name ? prev_order : nil
32 |
33 | if options[:default_order] == :desc
34 | new_order = current_order == 'desc' ? 'asc' : 'desc'
35 | else
36 | new_order = current_order == 'asc' ? 'desc' : 'asc'
37 | end
38 | options.delete(:default_order)
39 |
40 | html_options = args.first.is_a?(Hash) ? args.shift : {}
41 | css = ['sort_link', current_order].compact.join(' ')
42 | html_options[:class] = [css, html_options[:class]].compact.join(' ')
43 | options.merge!(
44 | builder.search_key => builder.search_attributes.merge(
45 | 'meta_sort' => [attr_name, new_order].join('.')
46 | )
47 | )
48 | link_to [ERB::Util.h(name), order_indicator_for(current_order)].compact.join(' ').html_safe,
49 | url_for(options),
50 | html_options
51 | end
52 |
53 | private
54 |
55 | def order_indicator_for(order)
56 | if order == 'asc'
57 | '▲'
58 | elsif order == 'desc'
59 | '▼'
60 | else
61 | nil
62 | end
63 | end
64 | end
65 | end
66 | end
67 |
--------------------------------------------------------------------------------
/lib/meta_search/locale/en.yml:
--------------------------------------------------------------------------------
1 | en:
2 | meta_search:
3 | or: 'or'
4 | predicates:
5 | equals: "%{attribute} equals"
6 | does_not_equal: "%{attribute} doesn't equal"
7 | contains: "%{attribute} contains"
8 | does_not_contain: "%{attribute} doesn't contain"
9 | starts_with: "%{attribute} starts with"
10 | does_not_start_with: "%{attribute} doesn't start with"
11 | ends_with: "%{attribute} ends with"
12 | does_not_end_with: "%{attribute} doesn't end with"
13 | greater_than: "%{attribute} greater than"
14 | less_than: "%{attribute} less than"
15 | greater_than_or_equal_to: "%{attribute} greater than or equal to"
16 | less_than_or_equal_to: "%{attribute} less than or equal to"
17 | in: "%{attribute} is one of"
18 | not_in: "%{attribute} isn't one of"
19 | is_true: "%{attribute} is true"
20 | is_false: "%{attribute} is false"
21 | is_present: "%{attribute} is present"
22 | is_blank: "%{attribute} is blank"
23 | is_null: "%{attribute} is null"
24 | is_not_null: "%{attribute} isn't null"
--------------------------------------------------------------------------------
/lib/meta_search/method.rb:
--------------------------------------------------------------------------------
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 | # search_methods 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 search_methods:
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 :type, 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 :splat_param and :type, search_methods accept the same :formatter
46 | # and :validator options that you would use when adding a new MetaSearch::Where:
47 | #
48 | # formatter is the Proc that will do any formatting to the variable passed to your method.
49 | # The default proc is {|param| param}, 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 | # validator 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 | # {|param| !param.blank?}, 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 type
91 | def cast_param(param)
92 | if type.is_a?(Array)
93 | unless param.is_a?(Array) && param.size == type.size
94 | num_params = param.is_a?(Array) ? param.size : 1
95 | raise ArgumentError, "Parameters supplied to #{name} could not be type cast -- #{num_params} values supplied, #{type.size} expected"
96 | end
97 | type.each_with_index do |t, i|
98 | param[i] = cast_attributes(t, param[i])
99 | end
100 | param
101 | else
102 | cast_attributes(type, param)
103 | end
104 | end
105 |
106 | # Evaluate the method in the context of the supplied relation and parameter
107 | def evaluate(relation, param)
108 | if splat_param?
109 | relation.send(name, *format_param(param))
110 | else
111 | relation.send(name, format_param(param))
112 | end
113 | end
114 |
115 | def splat_param?
116 | !!@splat_param
117 | end
118 |
119 | # Format a parameter for searching using the Method's defined formatter.
120 | def format_param(param)
121 | formatter.call(param)
122 | end
123 |
124 | # Validate the parameter for use in a search using the Method's defined validator.
125 | def validate(param)
126 | validator.call(param)
127 | end
128 | end
129 | end
--------------------------------------------------------------------------------
/lib/meta_search/model_compatibility.rb:
--------------------------------------------------------------------------------
1 | require 'meta_search/utility'
2 |
3 | module MetaSearch
4 |
5 | module ModelCompatibility
6 |
7 | def self.included(base)
8 | base.extend ClassMethods
9 | end
10 |
11 | def persisted?
12 | false
13 | end
14 |
15 | def to_key
16 | nil
17 | end
18 |
19 | def to_param
20 | nil
21 | end
22 |
23 | def to_model
24 | self
25 | end
26 | end
27 |
28 | class Name < String
29 | attr_reader :singular, :plural, :element, :collection, :partial_path, :human, :param_key, :route_key, :i18n_key
30 | alias_method :cache_key, :collection
31 |
32 | def initialize
33 | super("Search")
34 | @singular = "search".freeze
35 | @plural = "searches".freeze
36 | @element = "search".freeze
37 | @human = "Search".freeze
38 | @collection = "meta_search/searches".freeze
39 | @partial_path = "#{@collection}/#{@element}".freeze
40 | @param_key = "search".freeze
41 | @route_key = "searches".freeze
42 | @i18n_key = :meta_search
43 | end
44 | end
45 |
46 | module ClassMethods
47 | include Utility
48 |
49 | def model_name
50 | @_model_name ||= Name.new
51 | end
52 |
53 | def human_attribute_name(attribute, options = {})
54 | method_name = preferred_method_name(attribute)
55 |
56 | defaults = [:"meta_search.attributes.#{klass.model_name.i18n_key}.#{method_name || attribute}"]
57 |
58 | if method_name
59 | predicate = Where.get(method_name)[:name]
60 | predicate_attribute = method_name.sub(/_#{predicate}=?$/, '')
61 | predicate_attributes = predicate_attribute.split(/_or_/).map { |att|
62 | klass.human_attribute_name(att)
63 | }.join(" #{I18n.translate(:"meta_search.or", :default => 'or')} ")
64 | defaults << :"meta_search.predicates.#{predicate}"
65 | end
66 |
67 | defaults << options.delete(:default) if options[:default]
68 | defaults << attribute.to_s.humanize
69 |
70 | options.reverse_merge! :count => 1, :default => defaults, :attribute => predicate_attributes || klass.human_attribute_name(attribute)
71 | I18n.translate(defaults.shift, options)
72 | end
73 | end
74 |
75 | end
--------------------------------------------------------------------------------
/lib/meta_search/searches/active_record.rb:
--------------------------------------------------------------------------------
1 | require 'active_support/concern'
2 | require 'meta_search/method'
3 | require 'meta_search/builder'
4 |
5 | module MetaSearch
6 | module Searches
7 |
8 | module ActiveRecord
9 |
10 | def self.included(base)
11 | base.extend ClassMethods
12 |
13 | base.class_eval do
14 | class_attribute :_metasearch_include_attributes, :_metasearch_exclude_attributes
15 | class_attribute :_metasearch_include_associations, :_metasearch_exclude_associations
16 | class_attribute :_metasearch_methods
17 | self._metasearch_include_attributes =
18 | self._metasearch_exclude_attributes =
19 | self._metasearch_exclude_associations =
20 | self._metasearch_include_associations = {}
21 | self._metasearch_methods = {}
22 | end
23 | end
24 |
25 | module ClassMethods
26 | # Prepares the search to run against your model. Returns an instance of
27 | # MetaSearch::Builder, which behaves pretty much like an ActiveRecord::Relation,
28 | # in that it doesn't actually query the database until you do something that
29 | # requires it to do so.
30 | #
31 | # Options:
32 | #
33 | # * +params+ - a hash of valid searches with keys that are valid according to
34 | # the docs in MetaSearch::Where.
35 | # * +opts+ - A hash of additional information that will be passed through to
36 | # the search's Builder object. +search_key+, if present, will override the
37 | # default param name, 'search', in any sort_links generated by this Builder.
38 | # All other keys are passed untouched to the builder, and available from the
39 | # Builder's +options+ reader for use in :if blocks supplied to attr_searchable
40 | # and friends.
41 | def metasearch(params = nil, opts = nil)
42 | builder = Searches.for(self).new(self, opts || {})
43 | builder.build(params || {})
44 | end
45 |
46 | alias_method :search, :metasearch unless respond_to?(:search)
47 |
48 | def _metasearch_method_authorized?(name, metasearch_object)
49 | name = name.to_s
50 | meth = self._metasearch_methods[name]
51 | meth && (meth[:if] ? meth[:if].call(metasearch_object) : true)
52 | end
53 |
54 | def _metasearch_attribute_authorized?(name, metasearch_object)
55 | name = name.to_s
56 | if self._metasearch_include_attributes.empty?
57 | !_metasearch_excludes_attribute?(name, metasearch_object)
58 | else
59 | _metasearch_includes_attribute?(name, metasearch_object)
60 | end
61 | end
62 |
63 | def _metasearch_association_authorized?(name, metasearch_object)
64 | name = name.to_s
65 | if self._metasearch_include_associations.empty?
66 | !_metasearch_excludes_association?(name, metasearch_object)
67 | else
68 | _metasearch_includes_association?(name, metasearch_object)
69 | end
70 | end
71 |
72 | private
73 |
74 | # Excludes model attributes from searchability. This means that searches can't be created against
75 | # these columns, whether the search is based on this model, or the model's attributes are being
76 | # searched by association from another model. If a Comment belongs_to :article but declares
77 | # attr_unsearchable :user_id then Comment.search won't accept parameters
78 | # like :user_id_equals, nor will an Article.search accept the parameter
79 | # :comments_user_id_equals.
80 | def attr_unsearchable(*args)
81 | if table_exists?
82 | opts = args.extract_options!
83 | args.flatten.each do |attr|
84 | attr = attr.to_s
85 | raise(ArgumentError, "No persisted attribute (column) named #{attr} in #{self}") unless self.columns_hash.has_key?(attr)
86 | self._metasearch_exclude_attributes = self._metasearch_exclude_attributes.merge(
87 | attr => {
88 | :if => opts[:if]
89 | }
90 | )
91 | end
92 | end
93 | end
94 |
95 | # Like attr_unsearchable, but operates as a whitelist rather than blacklist. If both
96 | # attr_searchable and attr_unsearchable are present, the latter
97 | # is ignored.
98 | def attr_searchable(*args)
99 | if table_exists?
100 | opts = args.extract_options!
101 | args.flatten.each do |attr|
102 | attr = attr.to_s
103 | raise(ArgumentError, "No persisted attribute (column) named #{attr} in #{self}") unless self.columns_hash.has_key?(attr)
104 | self._metasearch_include_attributes = self._metasearch_include_attributes.merge(
105 | attr => {
106 | :if => opts[:if]
107 | }
108 | )
109 | end
110 | end
111 | end
112 |
113 | # Excludes model associations from searchability. This mean that searches can't be created against
114 | # these associations. An article that has_many :comments but excludes comments from
115 | # searching by declaring assoc_unsearchable :comments won't make any of the
116 | # comments_* methods available.
117 | def assoc_unsearchable(*args)
118 | opts = args.extract_options!
119 | args.flatten.each do |assoc|
120 | assoc = assoc.to_s
121 | raise(ArgumentError, "No such association #{assoc} in #{self}") unless self.reflect_on_all_associations.map {|a| a.name.to_s}.include?(assoc)
122 | self._metasearch_exclude_associations = self._metasearch_exclude_associations.merge(
123 | assoc => {
124 | :if => opts[:if]
125 | }
126 | )
127 | end
128 | end
129 |
130 | # As with attr_searchable this is the whitelist version of
131 | # assoc_unsearchable
132 | def assoc_searchable(*args)
133 | opts = args.extract_options!
134 | args.flatten.each do |assoc|
135 | assoc = assoc.to_s
136 | raise(ArgumentError, "No such association #{assoc} in #{self}") unless self.reflect_on_all_associations.map {|a| a.name.to_s}.include?(assoc)
137 | self._metasearch_include_associations = self._metasearch_include_associations.merge(
138 | assoc => {
139 | :if => opts[:if]
140 | }
141 | )
142 | end
143 | end
144 |
145 | def search_methods(*args)
146 | opts = args.extract_options!
147 | authorizer = opts.delete(:if)
148 | args.flatten.map(&:to_s).each do |arg|
149 | self._metasearch_methods = self._metasearch_methods.merge(
150 | arg => {
151 | :method => MetaSearch::Method.new(arg, opts),
152 | :if => authorizer
153 | }
154 | )
155 | end
156 | end
157 |
158 | alias_method :search_method, :search_methods
159 |
160 | def _metasearch_includes_attribute?(name, metasearch_object)
161 | attr = self._metasearch_include_attributes[name]
162 | attr && (attr[:if] ? attr[:if].call(metasearch_object) : true)
163 | end
164 |
165 | def _metasearch_excludes_attribute?(name, metasearch_object)
166 | attr = self._metasearch_exclude_attributes[name]
167 | attr && (attr[:if] ? attr[:if].call(metasearch_object) : true)
168 | end
169 |
170 | def _metasearch_includes_association?(name, metasearch_object)
171 | assoc = self._metasearch_include_associations[name]
172 | assoc && (assoc[:if] ? assoc[:if].call(metasearch_object) : true)
173 | end
174 |
175 | def _metasearch_excludes_association?(name, metasearch_object)
176 | assoc = self._metasearch_exclude_associations[name]
177 | assoc && (assoc[:if] ? assoc[:if].call(metasearch_object) : true)
178 | end
179 |
180 | end
181 | end
182 |
183 | def self.for(klass)
184 | DISPATCH[klass.name]
185 | end
186 |
187 | private
188 |
189 | DISPATCH = Hash.new do |hash, klass_name|
190 | class_name = klass_name.gsub('::', '_')
191 | hash[klass_name] = module_eval <<-RUBY_EVAL
192 | class #{class_name} < MetaSearch::Builder
193 | def self.klass
194 | ::#{klass_name}
195 | end
196 | end
197 |
198 | #{class_name}
199 | RUBY_EVAL
200 | end
201 |
202 | end
203 | end
--------------------------------------------------------------------------------
/lib/meta_search/utility.rb:
--------------------------------------------------------------------------------
1 | require 'meta_search/exceptions'
2 |
3 | module MetaSearch
4 | module Utility #:nodoc:
5 |
6 | TRUE_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE'].to_set
7 | FALSE_VALUES = [false, 0, '0', 'f', 'F', 'false', 'FALSE'].to_set
8 |
9 | private
10 |
11 | def preferred_method_name(method_id)
12 | method_name = method_id.to_s
13 | where = Where.new(method_name) rescue nil
14 | return nil unless where
15 | where.aliases.each do |a|
16 | break if method_name.sub!(/#{a}(=?)$/, "#{where.name}\\1")
17 | end
18 | method_name
19 | end
20 |
21 | def array_of_strings?(o)
22 | o.is_a?(Array) && o.all?{|obj| obj.is_a?(String)}
23 | end
24 |
25 | def array_of_arrays?(vals)
26 | vals.is_a?(Array) && vals.first.is_a?(Array)
27 | end
28 |
29 | def array_of_dates?(vals)
30 | vals.is_a?(Array) && vals.first.respond_to?(:to_time)
31 | end
32 |
33 | def cast_attributes(type, vals)
34 | if array_of_arrays?(vals)
35 | vals.map! {|v| cast_attributes(type, v)}
36 | # Need to make sure not to kill multiparam dates/times
37 | elsif vals.is_a?(Array) && (array_of_dates?(vals) || !(DATES+TIMES).include?(type))
38 | vals.map! {|v| cast_attribute(type, v)}
39 | else
40 | cast_attribute(type, vals)
41 | end
42 | end
43 |
44 | def cast_attribute(type, val)
45 | case type
46 | when *STRINGS
47 | val.respond_to?(:to_s) ? val.to_s : String.new(val)
48 | when *DATES
49 | if val.respond_to?(:to_date)
50 | val.to_date rescue nil
51 | else
52 | y, m, d = *[val].flatten
53 | m ||= 1
54 | d ||= 1
55 | Date.new(y,m,d) rescue nil
56 | end
57 | when *TIMES
58 | if val.is_a?(Array)
59 | y, m, d, hh, mm, ss = *[val].flatten
60 | Time.zone.local(y, m, d, hh, mm, ss) rescue nil
61 | else
62 | unless val.acts_like?(:time)
63 | val = val.is_a?(String) ? Time.zone.parse(val) : val.to_time rescue val
64 | end
65 | val.in_time_zone rescue nil
66 | end
67 | when *BOOLEANS
68 | if val.is_a?(String) && val.blank?
69 | nil
70 | else
71 | TRUE_VALUES.include?(val)
72 | end
73 | when :integer
74 | val.blank? ? nil : val.to_i
75 | when :float
76 | val.blank? ? nil : val.to_f
77 | when :decimal
78 | if val.blank?
79 | nil
80 | elsif val.class == BigDecimal
81 | val
82 | elsif val.respond_to?(:to_d)
83 | val.to_d
84 | else
85 | val.to_s.to_d
86 | end
87 | else
88 | raise TypeCastError, "Unable to cast columns of type #{type}"
89 | end
90 | end
91 |
92 | def collapse_multiparameter_options(opts)
93 | opts.keys.each do |k|
94 | if k.include?("(")
95 | real_attribute, position = k.split(/\(|\)/)
96 | cast = %w(a s i).include?(position.last) ? position.last : nil
97 | position = position.to_i - 1
98 | value = opts.delete(k)
99 | opts[real_attribute] ||= []
100 | opts[real_attribute][position] = if cast
101 | (value.blank? && cast == 'i') ? nil : value.send("to_#{cast}")
102 | else
103 | value
104 | end
105 | end
106 | end
107 | opts
108 | end
109 | end
110 | end
--------------------------------------------------------------------------------
/lib/meta_search/where.rb:
--------------------------------------------------------------------------------
1 | require 'meta_search/exceptions'
2 |
3 | module MetaSearch
4 | # Wheres are how MetaSearch does its magic. Wheres have a name (and possible aliases) which are
5 | # appended to your model and association attributes. When you instantiate a MetaSearch::Builder
6 | # against a model (manually or by calling your model's +search+ method) the builder responds to
7 | # methods named for your model's attributes and associations, suffixed by the name of the Where.
8 | #
9 | # These are the default Wheres, broken down by the types of ActiveRecord columns they can search
10 | # against:
11 | #
12 | # === All data types
13 | #
14 | # * _equals_ (alias: _eq_) - Just as it sounds.
15 | # * _does_not_equal_ (aliases: _ne_, _noteq_) - The opposite of equals, oddly enough.
16 | # * _in_ - Takes an array, matches on equality with any of the items in the array.
17 | # * _not_in_ (aliases: _ni_, _notin_) - Like above, but negated.
18 | # * _is_null_ - The column has an SQL NULL value.
19 | # * _is_not_null_ - The column contains anything but NULL.
20 | #
21 | # === Strings
22 | #
23 | # * _contains_ (aliases: _like_, _matches_) - Substring match.
24 | # * _does_not_contain_ (aliases: _nlike_, _nmatches_) - Negative substring match.
25 | # * _starts_with_ (alias: _sw_) - Match strings beginning with the entered term.
26 | # * _does_not_start_with_ (alias: _dnsw_) - The opposite of above.
27 | # * _ends_with_ (alias: _ew_) - Match strings ending with the entered term.
28 | # * _does_not_end_with_ (alias: _dnew_) - Negative of above.
29 | #
30 | # === Numbers, dates, and times
31 | #
32 | # * _greater_than_ (alias: _gt_) - Greater than.
33 | # * _greater_than_or_equal_to_ (aliases: _gte_, _gteq_) - Greater than or equal to.
34 | # * _less_than_ (alias: _lt_) - Less than.
35 | # * _less_than_or_equal_to_ (aliases: _lte_, _lteq_) - Less than or equal to.
36 | #
37 | # === Booleans
38 | #
39 | # * _is_true_ - Is true. Useful for a checkbox like "only show admin users".
40 | # * _is_false_ - The complement of _is_true_.
41 | #
42 | # === Non-boolean data types
43 | #
44 | # * _is_present_ - As with _is_true_, useful with a checkbox. Not NULL or the empty string.
45 | # * _is_blank_ - Returns records with a value of NULL or the empty string in the column.
46 | #
47 | # So, given a model like this...
48 | #
49 | # class Article < ActiveRecord::Base
50 | # belongs_to :author
51 | # has_many :comments
52 | # has_many :moderations, :through => :comments
53 | # end
54 | #
55 | # ...you might end up with attributes like title_contains,
56 | # comments_title_starts_with, moderations_value_less_than,
57 | # author_name_equals, and so on.
58 | #
59 | # Additionally, all of the above predicate types also have an _any and _all version, which
60 | # expects an array of the corresponding parameter type, and requires any or all of the
61 | # parameters to be a match, respectively. So:
62 | #
63 | # Article.search :author_name_starts_with_any => ['Jim', 'Bob', 'Fred']
64 | #
65 | # will match articles authored by Jimmy, Bobby, or Freddy, but not Winifred.
66 | class Where
67 | attr_reader :name, :aliases, :types, :cast, :predicate, :formatter, :validator
68 | def initialize(where)
69 | if [String,Symbol].include?(where.class)
70 | where = Where.get(where) or raise ArgumentError("A where could not be instantiated for the argument #{where}")
71 | end
72 | @name = where[:name]
73 | @aliases = where[:aliases]
74 | @types = where[:types]
75 | @cast = where[:cast]
76 | @predicate = where[:predicate]
77 | @validator = where[:validator]
78 | @formatter = where[:formatter]
79 | @splat_param = where[:splat_param]
80 | @skip_compounds = where[:skip_compounds]
81 | end
82 |
83 | def splat_param?
84 | !!@splat_param
85 | end
86 |
87 | def skip_compounds?
88 | !!@skip_compounds
89 | end
90 |
91 | # Format a parameter for searching using the Where's defined formatter.
92 | def format_param(param)
93 | formatter.call(param)
94 | end
95 |
96 | # Validate the parameter for use in a search using the Where's defined validator.
97 | def validate(param)
98 | validator.call(param)
99 | end
100 |
101 | # Evaluate the Where for the given relation, attribute, and parameter(s)
102 | def evaluate(relation, attributes, param)
103 | if splat_param?
104 | conditions = attributes.map {|a| a.send(predicate, *format_param(param))}
105 | else
106 | conditions = attributes.map {|a| a.send(predicate, format_param(param))}
107 | end
108 |
109 | relation.where(conditions.inject(nil) {|memo, c| memo ? memo.or(c) : c})
110 | end
111 |
112 | class << self
113 | # At application initialization, you can add additional custom Wheres to the mix.
114 | # in your application's config/initializers/meta_search.rb, place lines
115 | # like this:
116 | #
117 | # MetaSearch::Where.add :between, :btw,
118 | # :predicate => :in,
119 | # :types => [:integer, :float, :decimal, :date, :datetime, :timestamp, :time],
120 | # :formatter => Proc.new {|param| Range.new(param.first, param.last)},
121 | # :validator => Proc.new {|param|
122 | # param.is_a?(Array) && !(param[0].blank? || param[1].blank?)
123 | # }
124 | #
125 | # The first options are all names for the where. Well, the first is a name, the rest
126 | # are aliases, really. They will determine the suffix you will use to access your Where.
127 | #
128 | # types is an array of types the comparison is valid for. The where will not
129 | # be available against columns that are not one of these types. Default is +ALL_TYPES+,
130 | # Which is one of several MetaSearch constants available for type assignment (the others
131 | # being +DATES+, +TIIMES+, +STRINGS+, and +NUMBERS+).
132 | #
133 | # predicate is the Arel::Attribute predication (read: conditional operator) used
134 | # for the comparison. Default is :eq, or equality.
135 | #
136 | # formatter is the Proc that will do any formatting to the variables to be substituted.
137 | # The default proc is {|param| param}, which doesn't really do anything. If you pass a
138 | # string, it will be +eval+ed in the context of this Proc.
139 | #
140 | # For example, this is the definition of the "contains" Where:
141 | #
142 | # ['contains', 'like', {:types => STRINGS, :predicate => :matches, :formatter => '"%#{param}%"'}]
143 | #
144 | # Be sure to single-quote the string, so that variables aren't interpolated until later. If in doubt,
145 | # just use a Proc.
146 | #
147 | # validator is the Proc that will be used to check whether a parameter supplied to the
148 | # Where is valid. If it is not valid, it won't be used in the query. The default is
149 | # {|param| !param.blank?}, so that empty parameters aren't added to the search, but you
150 | # can get more complex if you desire, like the one in the between example, above.
151 | #
152 | # splat_param, if true, will cause the parameters sent to the predicate in question
153 | # to be splatted (converted to an argument list). This is not normally useful and defaults to
154 | # false, but is used when automatically creating compound Wheres (*_any, *_all) so that the
155 | # Arel attribute method gets the correct parameter list.
156 | #
157 | # skip_compounds will prevent creation of compound condition methods (ending in
158 | # _any_ or _all_) for situations where they wouldn't make sense, such as the built-in
159 | # conditions is_true and is_false.
160 | #
161 | # cast will override the normal cast of the parameter when using this Where
162 | # condition. Normally, the value supplied to a condition is cast to the type of the column
163 | # it's being compared against. In cases where this isn't desirable, because the value you
164 | # intend to accept isn't the same kind of data you'll be comparing against, you can override
165 | # that cast here, using one of the standard DB type symbols such as :integer, :string, :boolean
166 | # and so on.
167 | def add(*args)
168 | where = create_where_from_args(*args)
169 | create_where_compounds_for(where) unless where.skip_compounds?
170 | end
171 |
172 | # Returns the complete array of Wheres
173 | def all
174 | @@wheres
175 | end
176 |
177 | # Get the where matching a method or predicate.
178 | def get(method_id_or_predicate)
179 | return nil unless where_key = @@wheres.keys.
180 | sort {|a,b| b.length <=> a.length}.
181 | detect {|n| method_id_or_predicate.to_s.match(/#{n}=?$/)}
182 | where = @@wheres[where_key]
183 | where = @@wheres[where] if where.is_a?(String)
184 | where
185 | end
186 |
187 | # Set the wheres to their default values, removing any customized settings.
188 | def initialize_wheres
189 | @@wheres = {}
190 | DEFAULT_WHERES.each do |where|
191 | add(*where)
192 | end
193 | end
194 |
195 | private
196 |
197 | # "Creates" the Where by adding it (and its aliases) to the current hash of wheres. It then
198 | # instantiates a Where and returns it for use.
199 | def create_where_from_args(*args)
200 | opts = args.last.is_a?(Hash) ? args.pop : {}
201 | args = args.compact.flatten.map {|a| a.to_s }
202 | raise ArgumentError, "Name parameter required" if args.blank?
203 | opts[:name] ||= args.first
204 | opts[:types] ||= ALL_TYPES
205 | opts[:types] = [opts[:types]].flatten
206 | opts[:cast] = opts[:cast]
207 | opts[:predicate] ||= :eq
208 | opts[:splat_param] ||= false
209 | opts[:skip_compounds] ||= false
210 | opts[:formatter] ||= Proc.new {|param| param}
211 | if opts[:formatter].is_a?(String)
212 | formatter = opts[:formatter]
213 | opts[:formatter] = Proc.new {|param| eval formatter}
214 | end
215 | unless opts[:formatter].respond_to?(:call)
216 | raise ArgumentError, "Invalid formatter for #{opts[:name]}, should be a Proc or String."
217 | end
218 | opts[:validator] ||= Proc.new {|param| !param.blank?}
219 | unless opts[:validator].respond_to?(:call)
220 | raise ArgumentError, "Invalid validator for #{opts[:name]}, should be a Proc."
221 | end
222 | opts[:aliases] ||= [args - [opts[:name]]].flatten
223 | @@wheres ||= {}
224 | if @@wheres.has_key?(opts[:name])
225 | raise ArgumentError, "\"#{opts[:name]}\" is not available for use as a where name."
226 | end
227 | @@wheres[opts[:name]] = opts
228 | opts[:aliases].each do |a|
229 | if @@wheres.has_key?(a)
230 | opts[:aliases].delete(a)
231 | else
232 | @@wheres[a] = opts[:name]
233 | end
234 | end
235 | new(opts[:name])
236 | end
237 |
238 | # Takes the provided +where+ param and derives two additional Wheres from it, with the
239 | # name appended by _any/_all. These will use Arel's grouped predicate methods (matching
240 | # the same naming convention) to be invoked instead, with a list of possible/required
241 | # matches.
242 | def create_where_compounds_for(where)
243 | ['any', 'all'].each do |compound|
244 | args = [where.name, *where.aliases].map {|n| "#{n}_#{compound}"}
245 | create_where_from_args(*args + [{
246 | :types => where.types,
247 | :predicate => "#{where.predicate}_#{compound}".to_sym,
248 | # Only use valid elements in the array
249 | :formatter => Proc.new {|param|
250 | param.select {|p| where.validator.call(p)}.map {|p| where.formatter.call(p)}
251 | },
252 | # Compound where is valid if it has at least one element which is valid
253 | :validator => Proc.new {|param|
254 | param.is_a?(Array) &&
255 | !param.select {|p| where.validator.call(p)}.blank?}
256 | }]
257 | )
258 | end
259 | end
260 | end
261 | end
262 |
263 | Where.initialize_wheres
264 | end
--------------------------------------------------------------------------------
/meta_search.gemspec:
--------------------------------------------------------------------------------
1 | # Generated by jeweler
2 | # DO NOT EDIT THIS FILE DIRECTLY
3 | # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4 | # -*- encoding: utf-8 -*-
5 |
6 | Gem::Specification.new do |s|
7 | s.name = "meta_search"
8 | s.version = "1.1.3"
9 |
10 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11 | s.authors = ["Ernie Miller"]
12 | s.date = "2012-02-02"
13 | s.description = "\n Allows simple search forms to be created against an AR3 model\n and its associations, has useful view helpers for sort links\n and multiparameter fields as well.\n "
14 | s.email = "ernie@metautonomo.us"
15 | s.extra_rdoc_files = [
16 | "LICENSE",
17 | "README.rdoc"
18 | ]
19 | s.files = [
20 | ".document",
21 | ".gitmodules",
22 | "CHANGELOG",
23 | "Gemfile",
24 | "LICENSE",
25 | "README.rdoc",
26 | "Rakefile",
27 | "VERSION",
28 | "lib/meta_search.rb",
29 | "lib/meta_search/builder.rb",
30 | "lib/meta_search/exceptions.rb",
31 | "lib/meta_search/helpers.rb",
32 | "lib/meta_search/helpers/form_builder.rb",
33 | "lib/meta_search/helpers/form_helper.rb",
34 | "lib/meta_search/helpers/url_helper.rb",
35 | "lib/meta_search/locale/en.yml",
36 | "lib/meta_search/method.rb",
37 | "lib/meta_search/model_compatibility.rb",
38 | "lib/meta_search/searches/active_record.rb",
39 | "lib/meta_search/utility.rb",
40 | "lib/meta_search/where.rb",
41 | "meta_search.gemspec",
42 | "test/fixtures/companies.yml",
43 | "test/fixtures/company.rb",
44 | "test/fixtures/data_type.rb",
45 | "test/fixtures/data_types.yml",
46 | "test/fixtures/developer.rb",
47 | "test/fixtures/developers.yml",
48 | "test/fixtures/developers_projects.yml",
49 | "test/fixtures/note.rb",
50 | "test/fixtures/notes.yml",
51 | "test/fixtures/project.rb",
52 | "test/fixtures/projects.yml",
53 | "test/fixtures/schema.rb",
54 | "test/helper.rb",
55 | "test/locales/es.yml",
56 | "test/locales/flanders.yml",
57 | "test/test_search.rb",
58 | "test/test_view_helpers.rb"
59 | ]
60 | s.homepage = "http://metautonomo.us/projects/metasearch/"
61 | s.post_install_message = "\n*** Thanks for installing MetaSearch! ***\nBe sure to check out http://metautonomo.us/projects/metasearch/ for a\nwalkthrough of MetaSearch's features, and click the donate button if\nyou're feeling especially appreciative. It'd help me justify this\n\"open source\" stuff to my lovely wife. :)\n\n"
62 | s.require_paths = ["lib"]
63 | s.rubygems_version = "1.8.15"
64 | s.summary = "Object-based searching (and more) for simply creating search forms."
65 |
66 | if s.respond_to? :specification_version then
67 | s.specification_version = 3
68 |
69 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
70 | s.add_runtime_dependency(%q, ["~> 3.1"])
71 | s.add_runtime_dependency(%q, ["~> 3.1"])
72 | s.add_runtime_dependency(%q, ["~> 0.5.0"])
73 | s.add_runtime_dependency(%q, ["~> 3.1"])
74 | s.add_development_dependency(%q, ["~> 2.11"])
75 | else
76 | s.add_dependency(%q, ["~> 3.1"])
77 | s.add_dependency(%q, ["~> 3.1"])
78 | s.add_dependency(%q, ["~> 0.5.0"])
79 | s.add_dependency(%q, ["~> 3.1"])
80 | s.add_dependency(%q, ["~> 2.11"])
81 | end
82 | else
83 | s.add_dependency(%q, ["~> 3.1"])
84 | s.add_dependency(%q, ["~> 3.1"])
85 | s.add_dependency(%q, ["~> 0.5.0"])
86 | s.add_dependency(%q, ["~> 3.1"])
87 | s.add_dependency(%q, ["~> 2.11"])
88 | end
89 | end
90 |
91 |
--------------------------------------------------------------------------------
/test/fixtures/companies.yml:
--------------------------------------------------------------------------------
1 | initech:
2 | name : Initech
3 | id : 1
4 | created_at: 1999-02-19 08:00
5 | updated_at: 1999-02-19 08:00
6 |
7 | aos:
8 | name: Advanced Optical Solutions
9 | id : 2
10 | created_at: 2004-02-01 08:00
11 | updated_at: 2004-02-01 08:00
12 |
13 | mission_data:
14 | name: Mission Data
15 | id : 3
16 | created_at: 1996-09-21 08:00
17 | updated_at: 1996-09-21 08:00
--------------------------------------------------------------------------------
/test/fixtures/company.rb:
--------------------------------------------------------------------------------
1 | class Company < ActiveRecord::Base
2 | has_many :developers
3 | has_many :developer_notes, :through => :developers, :source => :notes
4 | has_many :slackers, :class_name => "Developer", :conditions => {:slacker => true}
5 | has_many :notes, :as => :notable
6 | has_many :data_types
7 |
8 | scope :backwards_name, lambda {|name| where(:name => name.reverse)}
9 | scope :with_slackers_by_name_and_salary_range,
10 | lambda {|name, low, high|
11 | joins(:slackers).where(:developers => {:name => name, :salary => low..high})
12 | }
13 | search_methods :backwards_name, :backwards_name_as_string, :if => proc {|s| s.options[:user] != 'blocked'}
14 | search_methods :with_slackers_by_name_and_salary_range,
15 | :splat_param => true, :type => [:string, :integer, :integer]
16 | attr_unsearchable :updated_at, :if => proc {|s| s.options[:user] == 'blocked' || !s.options[:user]}
17 | assoc_unsearchable :notes, :if => proc {|s| s.options[:user] == 'blocked' || !s.options[:user]}
18 |
19 | def self.backwards_name_as_string(name)
20 | name.reverse
21 | end
22 | end
--------------------------------------------------------------------------------
/test/fixtures/data_type.rb:
--------------------------------------------------------------------------------
1 | class DataType < ActiveRecord::Base
2 | belongs_to :company
3 | attr_unsearchable :str
4 | attr_protected :str
5 | end
--------------------------------------------------------------------------------
/test/fixtures/data_types.yml:
--------------------------------------------------------------------------------
1 | <% 1.upto(9) do |n| %>
2 | dt_<%= n %>:
3 | company_id: <%= n % 3 + 1 %>
4 | str : This string has <%= n %> exclamation points<%= '!' * n %>
5 | txt : <%= 'This is some text that may or may not repeat based on the value of n.' * n %>
6 | int : <%= n ** 3 %>
7 | flt : <%= n.to_f / 2.0 %>
8 | dec : <%= n.to_f ** (n + 0.1) %>
9 | dtm : <%= (Time.local(2009, 12, 24) + 86400 * n).in_time_zone.to_s(:db) %>
10 | tms : <%= (Time.local(2009, 12, 24) + 86400 * n).in_time_zone.to_s(:db) %>
11 | tim : <%= Time.local(2000, 01, 01, n+8, n).in_time_zone.to_s(:db) %>
12 | dat : <%= (Date.new(2009, 12, 24) + n).strftime("%Y-%m-%d") %>
13 | bin : <%= "BLOB#{n}" * n %>
14 | bln : <%= n % 2 > 0 ? true : false %>
15 | <% end %>
--------------------------------------------------------------------------------
/test/fixtures/developer.rb:
--------------------------------------------------------------------------------
1 | class Developer < ActiveRecord::Base
2 | belongs_to :company
3 | has_and_belongs_to_many :projects
4 | has_many :notes, :as => :notable
5 |
6 | attr_searchable :name, :salary, :if => proc {|s| !s.options[:user] || s.options[:user] == 'privileged'}
7 | assoc_searchable :notes, :projects, :company, :if => proc {|s| !s.options[:user] || s.options[:user] == 'privileged'}
8 |
9 | scope :sort_by_salary_and_name_asc, order('salary ASC, name ASC')
10 | scope :sort_by_salary_and_name_desc, order('salary DESC, name DESC')
11 | end
--------------------------------------------------------------------------------
/test/fixtures/developers.yml:
--------------------------------------------------------------------------------
1 | peter:
2 | id : 1
3 | company_id: 1
4 | name : Peter Gibbons
5 | salary : 100000
6 | slacker : true
7 |
8 | michael:
9 | id : 2
10 | company_id: 1
11 | name : Michael Bolton
12 | salary : 70000
13 | slacker : false
14 |
15 | samir:
16 | id : 3
17 | company_id: 1
18 | name : Samir Nagheenanajar
19 | salary : 65000
20 | slacker : false
21 |
22 | herb:
23 | id : 4
24 | company_id: 2
25 | name : Herb Myers
26 | salary : 50000
27 | slacker : false
28 |
29 | dude:
30 | id : 5
31 | company_id: 2
32 | name : Some Dude
33 | salary : 84000
34 | slacker : true
35 |
36 | ernie:
37 | id : 6
38 | company_id: 3
39 | name : Ernie Miller
40 | salary : 45000
41 | slacker : true
42 |
43 | someone:
44 | id : 7
45 | company_id: 3
46 | name : Someone Else
47 | salary : 70000
48 | slacker : true
49 |
50 | another:
51 | id : 8
52 | company_id: 3
53 | name : Another Guy
54 | salary : 80000
55 | slacker : false
56 |
57 | forgetful:
58 | id : 9
59 | company_id: 3
60 | name : Forgetful Notetaker
61 | salary : 40000
62 | slacker : false
63 |
--------------------------------------------------------------------------------
/test/fixtures/developers_projects.yml:
--------------------------------------------------------------------------------
1 | <% 1.upto(3) do |d| %>
2 | y2k_<%= d %>:
3 | developer_id: <%= d %>
4 | project_id : 1
5 | <% end %>
6 |
7 | virus:
8 | developer_id: 2
9 | project_id : 2
10 |
11 | <% 1.upto(8) do |d| %>
12 | awesome_<%= d %>:
13 | developer_id: <%= d %>
14 | project_id : 3
15 | <% end %>
16 |
17 | metasearch:
18 | developer_id: 6
19 | project_id : 4
20 |
21 | <% 4.upto(8) do |d| %>
22 | another_<%= d %>:
23 | developer_id: <%= d %>
24 | project_id : 5
25 | <% end %>
26 |
--------------------------------------------------------------------------------
/test/fixtures/note.rb:
--------------------------------------------------------------------------------
1 | class Note < ActiveRecord::Base
2 | belongs_to :notable, :polymorphic => true
3 | end
--------------------------------------------------------------------------------
/test/fixtures/notes.yml:
--------------------------------------------------------------------------------
1 | peter:
2 | notable_type: Developer
3 | notable_id : 1
4 | note : A straight shooter with upper management written all over him.
5 |
6 | michael:
7 | notable_type: Developer
8 | notable_id : 2
9 | note : Doesn't like the singer of the same name. The nerve!
10 |
11 | samir:
12 | notable_type: Developer
13 | notable_id : 3
14 | note : Naga.... Naga..... Not gonna work here anymore anyway.
15 |
16 | herb:
17 | notable_type: Developer
18 | notable_id : 4
19 | note : Will show you what he's doing.
20 |
21 | dude:
22 | notable_type: Developer
23 | notable_id : 5
24 | note : Nothing of note.
25 |
26 | ernie:
27 | notable_type: Developer
28 | notable_id : 6
29 | note : Complete slacker. Should probably be fired.
30 |
31 | someone:
32 | notable_type: Developer
33 | notable_id : 7
34 | note : Just another developer.
35 |
36 | another:
37 | notable_type: Developer
38 | notable_id : 8
39 | note : Placing a note in this guy's file for insubordination.
40 |
41 | initech:
42 | notable_type: Company
43 | notable_id : 1
44 | note : Innovation + Technology!
45 |
46 | aos:
47 | notable_type: Company
48 | notable_id : 2
49 | note : Advanced solutions of an optical nature.
50 |
51 | mission_data:
52 | notable_type: Company
53 | notable_id : 3
54 | note : Best design + development shop in the 'ville.
55 |
56 | y2k:
57 | notable_type: Project
58 | notable_id : 1
59 | note : It may have already passed but that's no excuse to be unprepared!
60 |
61 | virus:
62 | notable_type: Project
63 | notable_id : 2
64 | note : It could bring the company to its knees.
65 |
66 | awesome:
67 | notable_type: Project
68 | notable_id : 3
69 | note : This note is AWESOME!!!
70 |
71 | metasearch:
72 | notable_type: Project
73 | notable_id : 4
74 | note : A complete waste of the developer's time.
75 |
76 | another:
77 | notable_type: Project
78 | notable_id : 5
79 | note : This is another project note.
--------------------------------------------------------------------------------
/test/fixtures/project.rb:
--------------------------------------------------------------------------------
1 | class Project < ActiveRecord::Base
2 | has_and_belongs_to_many :developers
3 | has_many :notes, :as => :notable
4 | end
--------------------------------------------------------------------------------
/test/fixtures/projects.yml:
--------------------------------------------------------------------------------
1 | y2k:
2 | estimated_hours: 1000
3 | name : Y2K Software Updates
4 | id : 1
5 |
6 | virus:
7 | estimated_hours: 80
8 | name : Virus
9 | id : 2
10 |
11 | awesome:
12 | estimated_hours: 100
13 | name : Do something awesome
14 | id : 3
15 |
16 | metasearch:
17 | estimated_hours: 100
18 | name : MetaSearch Development
19 | id : 4
20 |
21 | another:
22 | estimated_hours: 120
23 | name : Another Project
24 | id : 5
25 |
26 | nil:
27 | estimated_hours: 1000
28 | name :
29 | id : 6
30 |
31 | blank:
32 | estimated_hours: 1000
33 | name : ""
34 | id : 7
--------------------------------------------------------------------------------
/test/fixtures/schema.rb:
--------------------------------------------------------------------------------
1 | ActiveRecord::Schema.define do
2 |
3 | create_table "companies", :force => true do |t|
4 | t.string "name"
5 | t.datetime "created_at"
6 | t.datetime "updated_at"
7 | end
8 |
9 | create_table "developers", :force => true do |t|
10 | t.integer "company_id"
11 | t.string "name"
12 | t.integer "salary"
13 | t.boolean "slacker"
14 | end
15 |
16 | create_table "projects", :force => true do |t|
17 | t.string "name"
18 | t.float "estimated_hours"
19 | end
20 |
21 | create_table "developers_projects", :id => false, :force => true do |t|
22 | t.integer "developer_id"
23 | t.integer "project_id"
24 | end
25 |
26 | create_table "notes", :force => true do |t|
27 | t.string "notable_type"
28 | t.integer "notable_id"
29 | t.string "note"
30 | end
31 |
32 | create_table "data_types", :force => true do |t|
33 | t.integer "company_id"
34 | t.string "str"
35 | t.text "txt"
36 | t.integer "int"
37 | t.float "flt"
38 | t.decimal "dec"
39 | t.datetime "dtm"
40 | t.timestamp "tms"
41 | t.time "tim"
42 | t.date "dat"
43 | t.binary "bin"
44 | t.boolean "bln"
45 | end
46 |
47 | end
--------------------------------------------------------------------------------
/test/helper.rb:
--------------------------------------------------------------------------------
1 | require 'rubygems'
2 | require 'test/unit'
3 | require 'shoulda'
4 | require 'active_support/time'
5 | require 'active_record'
6 | require 'active_record/fixtures'
7 | require 'action_view'
8 | require 'meta_search'
9 |
10 | FIXTURES_PATH = File.join(File.dirname(__FILE__), 'fixtures')
11 |
12 | Time.zone = 'Eastern Time (US & Canada)'
13 |
14 | ActiveRecord::Base.establish_connection(
15 | :adapter => defined?(JRUBY_VERSION) ? 'jdbcsqlite3' : 'sqlite3',
16 | :database => ':memory:'
17 | )
18 |
19 | dep = defined?(ActiveSupport::Dependencies) ? ActiveSupport::Dependencies : ::Dependencies
20 | dep.autoload_paths.unshift FIXTURES_PATH
21 |
22 | ActiveRecord::Base.silence do
23 | ActiveRecord::Migration.verbose = false
24 | load File.join(FIXTURES_PATH, 'schema.rb')
25 | end
26 |
27 | ActiveRecord::Fixtures.create_fixtures(FIXTURES_PATH, ActiveRecord::Base.connection.tables)
28 |
29 | I18n.load_path += Dir[File.join(File.dirname(__FILE__), 'locales', '*.yml')]
30 |
31 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
32 | $LOAD_PATH.unshift(File.dirname(__FILE__))
33 |
34 | class Test::Unit::TestCase
35 | def self.context_a_search_against(name, object, &block)
36 | context "A search against #{name}" do
37 | setup do
38 | @s = object.search
39 | end
40 |
41 | merge_block(&block) if block_given?
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/test/locales/es.yml:
--------------------------------------------------------------------------------
1 | es:
2 | activerecord:
3 | attributes:
4 | company:
5 | name: Nombre
--------------------------------------------------------------------------------
/test/locales/flanders.yml:
--------------------------------------------------------------------------------
1 | flanders:
2 | activerecord:
3 | attributes:
4 | company:
5 | name: "Company name-diddly"
6 | developer:
7 | name: "Developer name-diddly"
8 | salary: "Developer salary-doodly"
9 | meta_search:
10 | or: 'or-diddly'
11 | predicates:
12 | contains: "%{attribute} contains-diddly"
13 | equals: "%{attribute} equals-diddly"
14 | attributes:
15 | company:
16 | reverse_name: "Company reverse name-diddly"
17 | developer:
18 | name_contains: "Developer name-diddly contains-aroonie"
--------------------------------------------------------------------------------
/test/test_search.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 |
3 | class TestSearch < Test::Unit::TestCase
4 |
5 | context "A Company search where options[:user] = 'blocked'" do
6 | setup do
7 | @s = Company.search({}, :user => 'blocked')
8 | end
9 |
10 | should "not respond_to? a search against backwards_name" do
11 | assert !@s.respond_to?(:backwards_name), "The search responded to :backwards_name"
12 | end
13 |
14 | should "raise an error if we try to search on backwards_name" do
15 | assert_raise NoMethodError do
16 | @s.backwards_name = 'blah'
17 | end
18 | end
19 |
20 | should "not respond_to? a search against updated_at_eq" do
21 | assert !@s.respond_to?(:updated_at_eq), "The search responded to :updated_at_eq"
22 | end
23 |
24 | should "raise an error if we try to search on updated_at" do
25 | assert_raise NoMethodError do
26 | @s.updated_at_eq = 'blah'
27 | end
28 | end
29 |
30 | should "not respond_to? a search against notes_note_matches" do
31 | assert !@s.respond_to?(:notes_note_matches), "The search responded to :notes_note_matches"
32 | end
33 |
34 | should "raise an error if we try to search on notes_note_matches" do
35 | assert_raise NoMethodError do
36 | @s.notes_note_matches = '%blah%'
37 | end
38 | end
39 | end
40 |
41 | context "A Developer search where options[:user] = 'privileged'" do
42 | setup do
43 | @s = Developer.search({}, :user => 'privileged')
44 | end
45 |
46 | should "respond_to? a search against name_eq" do
47 | assert_respond_to @s, :name_eq
48 | end
49 |
50 | should "not raise an error on a search against name_eq" do
51 | assert_nothing_raised do
52 | @s.name_eq = 'blah'
53 | end
54 | end
55 |
56 | should "respond_to? a search against company_name_eq" do
57 | assert_respond_to @s, :company_name_eq
58 | end
59 |
60 | should "not raise an error on a search against name_eq" do
61 | assert_nothing_raised do
62 | @s.company_name_eq = 'blah'
63 | end
64 | end
65 |
66 | should "respond_to? a search against company_updated_at_eq" do
67 | assert_respond_to @s, :company_updated_at_eq
68 | end
69 |
70 | should "not raise an error on a search against company_updated_at_eq" do
71 | assert_nothing_raised do
72 | @s.company_updated_at_eq = Time.now
73 | end
74 | end
75 | end
76 |
77 | context "A Developer search" do
78 | setup do
79 | @s = Developer.search({:name_equals=>"Forgetful Notetaker"})
80 | end
81 |
82 | context "without any opts" do
83 | should "find a null entry when searching notes" do
84 | assert_equal 1, @s.notes_note_is_null(true).all.size
85 | end
86 |
87 | should "find no non-null entry when searching notes" do
88 | assert_equal 0, @s.notes_note_is_not_null(true).all.size
89 | end
90 | end
91 |
92 | context "with outer join specified" do
93 | setup do
94 | @s = Developer.search({:name_equals => "Forgetful Notetaker"}, :join_type => :outer)
95 | end
96 |
97 | should "find a null entry when searching notes" do
98 | assert_equal 1, @s.notes_note_is_null(true).all.size
99 | end
100 |
101 | should "find no non-null entry when searching notes" do
102 | assert_equal 0, @s.notes_note_is_not_null(true).all.size
103 | end
104 | end
105 |
106 | context "with inner join specified" do
107 | setup do
108 | @s = Developer.search({:name_equals=>"Forgetful Notetaker"}, :join_type => :inner)
109 | end
110 |
111 | should "find no null entry when searching notes" do
112 | assert_equal 0, @s.notes_note_is_null(true).all.size
113 | end
114 |
115 | should "find no non-null entry when searching notes" do
116 | assert_equal 0, @s.notes_note_is_not_null(true).all.size
117 | end
118 | end
119 |
120 |
121 | end
122 |
123 | [{:name => 'Company', :object => Company},
124 | {:name => 'Company as a Relation', :object => Company.scoped}].each do |object|
125 | context_a_search_against object[:name], object[:object] do
126 | should "have a relation attribute which is an ActiveRecord::Relation" do
127 | assert_equal ActiveRecord::Relation, @s.relation.class
128 | end
129 |
130 | should "have a base attribute which is a Class inheriting from ActiveRecord::Base" do
131 | assert_equal Company, @s.base
132 | assert_contains @s.base.ancestors, ActiveRecord::Base
133 | end
134 |
135 | should "have an association named developers" do
136 | assert @s.get_association(:developers)
137 | end
138 |
139 | should "respond_to? a search against a developer attribute" do
140 | assert_respond_to @s, :developers_name_eq
141 | end
142 |
143 | should "have a column named name" do
144 | assert @s.get_column(:name)
145 | end
146 |
147 | should "respond_to? a search against name" do
148 | assert_respond_to @s, :name_eq
149 | end
150 |
151 | should "respond_to? a search against backwards_name" do
152 | assert_respond_to @s, :backwards_name
153 | end
154 |
155 | should "exclude the column named updated_at" do
156 | assert_nil @s.get_column(:updated_at)
157 | end
158 |
159 | should "not respond_to? updated_at" do
160 | assert !@s.respond_to?(:updated_at), "The search responded to :updated_at"
161 | end
162 |
163 | should "raise an error if we try to search on updated_at" do
164 | assert_raise NoMethodError do
165 | @s.updated_at_eq = [2009, 1, 1]
166 | end
167 | end
168 |
169 | should "exclude the association named notes" do
170 | assert_nil @s.get_association(:notes)
171 | end
172 |
173 | should "not respond_to? notes_note_eq" do
174 | assert !@s.respond_to?(:notes_note_eq), "The search responded to :notes_note_eq"
175 | end
176 |
177 | should "raise an error if we try to search on notes" do
178 | assert_raise NoMethodError do
179 | @s.notes_note_eq = 'Blah'
180 | end
181 | end
182 |
183 | should "honor its associations' excluded attributes" do
184 | assert_nil @s.get_attribute(:data_types_str)
185 | end
186 |
187 | should "not respond_to? data_types_str_eq" do
188 | assert !@s.respond_to?(:data_types_str_eq), "The search responded to :data_types_str_eq"
189 | end
190 |
191 | should "respond_to? data_types_bln_eq" do
192 | assert_respond_to @s, :data_types_bln_eq
193 | end
194 |
195 | should "raise an error if we try to search data_types.str" do
196 | assert_raise NoMethodError do
197 | @s.data_types_str_eq = 'Blah'
198 | end
199 | end
200 |
201 | should "raise an error when MAX_JOIN_DEPTH is exceeded" do
202 | assert_raise MetaSearch::JoinDepthError do
203 | @s.developers_company_developers_company_developers_name_equals = "Ernie Miller"
204 | end
205 | end
206 |
207 | context "when meta_sort value is empty string" do
208 | setup do
209 | @s.meta_sort = ''
210 | end
211 |
212 | should "not raise an error, just ignore sorting" do
213 | assert_nothing_raised do
214 | assert_equal Company.all, @s.all
215 | end
216 | end
217 | end
218 |
219 | should "sort by name in ascending order" do
220 | @s.meta_sort = 'name.asc'
221 | assert_equal Company.order('name asc').all,
222 | @s.all
223 | end
224 |
225 | should "sort by name in ascending order as a method call" do
226 | @s.meta_sort 'name.asc'
227 | assert_equal Company.order('name asc').all,
228 | @s.all
229 | end
230 |
231 | should "sort by name in descending order" do
232 | @s.meta_sort = 'name.desc'
233 | assert_equal Company.order('name desc').all,
234 | @s.all
235 | end
236 |
237 | context "where name contains optical" do
238 | setup do
239 | @s.name_contains = 'optical'
240 | end
241 |
242 | should "return one result" do
243 | assert_equal 1, @s.all.size
244 | end
245 |
246 | should "return a company named Advanced Optical Solutions" do
247 | assert_contains @s.all, Company.where(:name => 'Advanced Optical Solutions').first
248 | end
249 |
250 | should "not return a company named Initech" do
251 | assert_does_not_contain @s.all, Company.where(:name => "Initech").first
252 | end
253 | end
254 |
255 | context "where name contains optical as a method call" do
256 | setup do
257 | @s.name_contains 'optical'
258 | end
259 |
260 | should "return one result" do
261 | assert_equal 1, @s.all.size
262 | end
263 |
264 | should "return a company named Advanced Optical Solutions" do
265 | assert_contains @s.all, Company.where(:name => 'Advanced Optical Solutions').first
266 | end
267 |
268 | should "not return a company named Initech" do
269 | assert_does_not_contain @s.all, Company.where(:name => "Initech").first
270 | end
271 | end
272 |
273 | context "where developer name starts with Ernie" do
274 | setup do
275 | @s.developers_name_starts_with = 'Ernie'
276 | end
277 |
278 | should "return one result" do
279 | assert_equal 1, @s.all.size
280 | end
281 |
282 | should "return a company named Mission Data" do
283 | assert_contains @s.all, Company.where(:name => 'Mission Data').first
284 | end
285 |
286 | should "not return a company named Initech" do
287 | assert_does_not_contain @s.all, Company.where(:name => "Initech").first
288 | end
289 |
290 | context "and slackers salary is greater than $70k" do
291 | setup do
292 | @s.slackers_salary_gt = 70000
293 | end
294 |
295 | should "return no results" do
296 | assert_equal 0, @s.all.size
297 | end
298 |
299 | should "join developers twice" do
300 | assert @s.to_sql.match(/join\s+"?developers"?.*join\s+"?developers"?/i)
301 | end
302 |
303 | should "alias the second join of developers" do
304 | assert @s.to_sql.match(/join\s+"?developers"?\s+"?slackers_companies"?/i)
305 | end
306 | end
307 | end
308 |
309 | context "where developer note indicates he will crack yo skull" do
310 | setup do
311 | @s.developer_notes_note_equals = "Will show you what he's doing."
312 | end
313 |
314 | should "return one result" do
315 | assert_equal 1, @s.all.size
316 | end
317 |
318 | should "return a company named Advanced Optical Solutions" do
319 | assert_contains @s.all, Company.where(:name => 'Advanced Optical Solutions').first
320 | end
321 |
322 | should "not return a company named Mission Data" do
323 | assert_does_not_contain @s.all, Company.where(:name => "Mission Data").first
324 | end
325 | end
326 |
327 | context "where developer note indicates he will crack yo skull through two associations" do
328 | setup do
329 | @s.developers_notes_note_equals = "Will show you what he's doing."
330 | end
331 |
332 | should "return one result" do
333 | assert_equal 1, @s.all.size
334 | end
335 |
336 | should "return a company named Advanced Optical Solutions" do
337 | assert_contains @s.all, Company.where(:name => 'Advanced Optical Solutions').first
338 | end
339 |
340 | should "not return a company named Mission Data" do
341 | assert_does_not_contain @s.all, Company.where(:name => "Mission Data").first
342 | end
343 | end
344 |
345 | context "where developer note indicates he will crack yo skull through four associations" do
346 | setup do
347 | @s.developers_company_developers_notes_note_equals = "Will show you what he's doing."
348 | end
349 |
350 | should "return two results, one of which is a duplicate due to joins" do
351 | assert_equal 2, @s.all.size
352 | assert_equal 1, @s.all.uniq.size
353 | end
354 |
355 | should "return a company named Advanced Optical Solutions" do
356 | assert_contains @s.all, Company.where(:name => 'Advanced Optical Solutions').first
357 | end
358 |
359 | should "not return a company named Mission Data" do
360 | assert_does_not_contain @s.all, Company.where(:name => "Mission Data").first
361 | end
362 | end
363 |
364 | context "where backwards name is hcetinI as a method call" do
365 | setup do
366 | @s.backwards_name 'hcetinI'
367 | end
368 |
369 | should "return 1 result" do
370 | assert_equal 1, @s.all.size
371 | end
372 |
373 | should "return a company named Initech" do
374 | assert_contains @s.all, Company.where(:name => 'Initech').first
375 | end
376 | end
377 |
378 | context "where backwards name is hcetinI" do
379 | setup do
380 | @s.backwards_name = 'hcetinI'
381 | end
382 |
383 | should "return 1 result" do
384 | assert_equal 1, @s.all.size
385 | end
386 |
387 | should "return a company named Initech" do
388 | assert_contains @s.all, Company.where(:name => 'Initech').first
389 | end
390 | end
391 |
392 | context "where with_slackers_by_name_and_salary_range is sent an array with 3 values" do
393 | setup do
394 | @s.with_slackers_by_name_and_salary_range = ['Peter Gibbons', 90000, 110000]
395 | end
396 |
397 | should "return 1 result" do
398 | assert_equal 1, @s.all.size
399 | end
400 |
401 | should "return a company named Initech" do
402 | assert_contains @s.all, Company.where(:name => 'Initech').first
403 | end
404 | end
405 |
406 | should "raise an error when the wrong number of parameters would be supplied to a custom search" do
407 | assert_raise ArgumentError do
408 | @s.with_slackers_by_name_and_salary_range = ['Peter Gibbons', 90000]
409 | end
410 | end
411 |
412 | should "raise an error when a custom search method does not return a relation" do
413 | assert_raise MetaSearch::NonRelationReturnedError do
414 | @s.backwards_name_as_string = 'hcetinI'
415 | end
416 | end
417 | end
418 | end
419 |
420 | [{:name => 'Developer', :object => Developer},
421 | {:name => 'Developer as a Relation', :object => Developer.scoped}].each do |object|
422 | context_a_search_against object[:name], object[:object] do
423 | should "exclude the column named company_id" do
424 | assert_nil @s.get_column(:company_id)
425 | end
426 |
427 | should "have an association named projects" do
428 | assert @s.get_association(:projects)
429 | end
430 |
431 | context "sorted by company name in ascending order" do
432 | setup do
433 | @s.meta_sort = 'company_name.asc'
434 | end
435 |
436 | should "sort by company name in ascending order" do
437 | assert_equal Developer.joins(:company).order('companies.name asc').all,
438 | @s.all
439 | end
440 | end
441 |
442 | context "sorted by company name in descending order" do
443 | setup do
444 | @s.meta_sort = 'company_name.desc'
445 | end
446 |
447 | should "sort by company name in descending order" do
448 | assert_equal Developer.joins(:company).order('companies.name desc').all,
449 | @s.all
450 | end
451 | end
452 |
453 | context "sorted by salary and name in descending order" do
454 | setup do
455 | @s.meta_sort = 'salary_and_name.desc'
456 | end
457 |
458 | should "sort by salary and name in descending order" do
459 | assert_equal Developer.order('salary DESC, name DESC').all,
460 | @s.all
461 | end
462 | end
463 |
464 | context "where developer is Bob-approved" do
465 | setup do
466 | @s.notes_note_equals = "A straight shooter with upper management written all over him."
467 | end
468 |
469 | should "return Peter Gibbons" do
470 | assert_contains @s.all, Developer.where(:name => 'Peter Gibbons').first
471 | end
472 | end
473 |
474 | context "where name or company name starts with m" do
475 | setup do
476 | @s.name_or_company_name_starts_with = "m"
477 | end
478 |
479 | should "return Michael Bolton and all employees of Mission Data" do
480 | assert_equal @s.all, Developer.where(:name => 'Michael Bolton').all +
481 | Company.where(:name => 'Mission Data').first.developers
482 | end
483 | end
484 |
485 | context "where name ends with Miller" do
486 | setup do
487 | @s.name_ends_with = 'Miller'
488 | end
489 |
490 | should "return one result" do
491 | assert_equal 1, @s.all.size
492 | end
493 |
494 | should "return a developer named Ernie Miller" do
495 | assert_contains @s.all, Developer.where(:name => 'Ernie Miller').first
496 | end
497 |
498 | should "not return a developer named Herb Myers" do
499 | assert_does_not_contain @s.all, Developer.where(:name => "Herb Myers").first
500 | end
501 | end
502 |
503 | context "where name starts with any of Ernie, Herb, or Peter" do
504 | setup do
505 | @s.name_starts_with_any = ['Ernie', 'Herb', 'Peter']
506 | end
507 |
508 | should "return three results" do
509 | assert_equal 3, @s.all.size
510 | end
511 |
512 | should "return a developer named Ernie Miller" do
513 | assert_contains @s.all, Developer.where(:name => 'Ernie Miller').first
514 | end
515 |
516 | should "not return a developer named Samir Nagheenanajar" do
517 | assert_does_not_contain @s.all, Developer.where(:name => "Samir Nagheenanajar").first
518 | end
519 | end
520 |
521 | context "where name does not equal Ernie Miller" do
522 | setup do
523 | @s.name_ne = 'Ernie Miller'
524 | end
525 |
526 | should "return eight results" do
527 | assert_equal 8, @s.all.size
528 | end
529 |
530 | should "not return a developer named Ernie Miller" do
531 | assert_does_not_contain @s.all, Developer.where(:name => "Ernie Miller").first
532 | end
533 | end
534 |
535 | context "where name contains all of a, e, and i" do
536 | setup do
537 | @s.name_contains_all = ['a', 'e', 'i']
538 | end
539 |
540 | should "return two results" do
541 | assert_equal 2, @s.all.size
542 | end
543 |
544 | should "return a developer named Samir Nagheenanajar" do
545 | assert_contains @s.all, Developer.where(:name => "Samir Nagheenanajar").first
546 | end
547 |
548 | should "not return a developer named Ernie Miller" do
549 | assert_does_not_contain @s.all, Developer.where(:name => 'Ernie Miller').first
550 | end
551 | end
552 |
553 | context "where project estimated hours are greater than or equal to 1000" do
554 | setup do
555 | @s.projects_estimated_hours_gte = 1000
556 | end
557 |
558 | should "return three results" do
559 | assert_equal 3, @s.all.size
560 | end
561 |
562 | should "return these developers" do
563 | assert_same_elements @s.all.collect {|d| d.name},
564 | ['Peter Gibbons', 'Michael Bolton', 'Samir Nagheenanajar']
565 | end
566 | end
567 |
568 | context "where project estimated hours are greater than 1000" do
569 | setup do
570 | @s.projects_estimated_hours_gt = 1000
571 | end
572 |
573 | should "return no results" do
574 | assert_equal 0, @s.all.size
575 | end
576 | end
577 |
578 | context "where developer is named Ernie Miller by polymorphic belongs_to against an association" do
579 | setup do
580 | @s.notes_notable_developer_type_name_equals = "Ernie Miller"
581 | end
582 |
583 | should "return one result" do
584 | assert_equal 1, @s.all.size
585 | end
586 |
587 | should "return a developer named Ernie Miller" do
588 | assert_contains @s.all, Developer.where(:name => 'Ernie Miller').first
589 | end
590 |
591 | should "not return a developer named Herb Myers" do
592 | assert_does_not_contain @s.all, Developer.where(:name => "Herb Myers").first
593 | end
594 | end
595 | end
596 | end
597 |
598 | [{:name => 'DataType', :object => DataType},
599 | {:name => 'DataType as a Relation', :object => DataType.scoped}].each do |object|
600 | context_a_search_against object[:name], object[:object] do
601 | should "raise an error on a contains search against a boolean column" do
602 | assert_raise NoMethodError do
603 | @s.bln_contains = "true"
604 | end
605 | end
606 |
607 | context "where boolean column equals true" do
608 | setup do
609 | @s.bln_equals = true
610 | end
611 |
612 | should "return five results" do
613 | assert_equal 5, @s.all.size
614 | end
615 |
616 | should "contain no results with a false boolean column" do
617 | assert_does_not_contain @s.all.collect {|r| r.bln}, false
618 | end
619 | end
620 |
621 | context "where boolean column is_true" do
622 | setup do
623 | @s.bln_is_true = true
624 | end
625 |
626 | should "return five results" do
627 | assert_equal 5, @s.all.size
628 | end
629 |
630 | should "contain no results with a false boolean column" do
631 | assert_does_not_contain @s.all.collect {|r| r.bln}, false
632 | end
633 | end
634 |
635 | context "where boolean column equals false" do
636 | setup do
637 | @s.bln_equals = false
638 | end
639 |
640 | should "return four results" do
641 | assert_equal 4, @s.all.size
642 | end
643 |
644 | should "contain no results with a true boolean column" do
645 | assert_does_not_contain @s.all.collect {|r| r.bln}, true
646 | end
647 | end
648 |
649 | context "where boolean column is_false" do
650 | setup do
651 | @s.bln_is_false = true
652 | end
653 |
654 | should "return four results" do
655 | assert_equal 4, @s.all.size
656 | end
657 |
658 | should "contain no results with a true boolean column" do
659 | assert_does_not_contain @s.all.collect {|r| r.bln}, true
660 | end
661 | end
662 |
663 | context "where date column is Christmas 2009 by array" do
664 | setup do
665 | @s.dat_equals = [2009, 12, 25]
666 | end
667 |
668 | should "return one result" do
669 | assert_equal 1, @s.all.size
670 | end
671 |
672 | should "contain a result with Christmas 2009 as its date" do
673 | assert_equal Date.parse('2009/12/25'), @s.first.dat
674 | end
675 | end
676 |
677 | context "where date column is Christmas 2009 by Date object" do
678 | setup do
679 | @s.dat_equals = Date.new(2009, 12, 25)
680 | end
681 |
682 | should "return one result" do
683 | assert_equal 1, @s.all.size
684 | end
685 |
686 | should "contain a result with Christmas 2009 as its date" do
687 | assert_equal Date.parse('2009/12/25'), @s.first.dat
688 | end
689 | end
690 |
691 | context "where time column is > 1:00 PM and < 3:30 PM" do
692 | setup do
693 | @s.tim_gt = Time.parse('2000-01-01 13:00') # Rails "dummy time" format
694 | @s.tim_lt = Time.parse('2000-01-01 15:30') # Rails "dummy time" format
695 | end
696 |
697 | should "return three results" do
698 | assert_equal 3, @s.all.size
699 | end
700 |
701 | should "not contain results with time column before or after constraints" do
702 | assert_equal [], @s.all.select {|r|
703 | r.tim < Time.parse('2000-01-01 13:00') || r.tim > Time.parse('2000-01-01 15:30')
704 | }
705 | end
706 | end
707 |
708 | context "where timestamp column is in the year 2010" do
709 | setup do
710 | @s.tms_gte = Time.utc(2010, 1, 1)
711 | end
712 |
713 | should "return two results" do
714 | assert_equal 2, @s.all.size
715 | end
716 |
717 | should "not contain results with timestamp column before 2010" do
718 | assert_equal [], @s.all.select {|r|
719 | r.tms < Time.utc(2010, 1, 1)
720 | }
721 | end
722 | end
723 |
724 | context "where timestamp column is before the year 2010" do
725 | setup do
726 | @s.tms_lt = Time.utc(2010, 1, 1)
727 | end
728 |
729 | should "return seven results" do
730 | assert_equal 7, @s.all.size
731 | end
732 |
733 | should "not contain results with timestamp in 2010" do
734 | assert_equal [], @s.all.select {|r|
735 | r.tms >= Time.utc(2010, 1, 1)
736 | }
737 | end
738 | end
739 |
740 | context "where decimal column is > 5000" do
741 | setup do
742 | @s.dec_gt = 5000
743 | end
744 |
745 | should "return four results" do
746 | assert_equal 4, @s.all.size
747 | end
748 |
749 | should "not contain results with decimal column <= 5000" do
750 | assert_equal [], @s.all.select {|r|
751 | r.dec <= 5000
752 | }
753 | end
754 | end
755 |
756 | context "where float column is between 2.5 and 3.5" do
757 | setup do
758 | @s.flt_gte = 2.5
759 | @s.flt_lte = 3.5
760 | end
761 |
762 | should "return three results" do
763 | assert_equal 3, @s.all.size
764 | end
765 |
766 | should "not contain results with float column outside constraints" do
767 | assert_equal [], @s.all.select {|r|
768 | r.flt < 2.5 || r.flt > 3.5
769 | }
770 | end
771 | end
772 |
773 | context "where integer column is in the set (1, 8, 729)" do
774 | setup do
775 | @s.int_in = [1, 8, 729]
776 | end
777 |
778 | should "return three results" do
779 | assert_equal 3, @s.all.size
780 | end
781 |
782 | should "not contain results outside the specified set" do
783 | assert_equal [], @s.all.select {|r|
784 | ![1, 8, 729].include?(r.int)
785 | }
786 | end
787 | end
788 |
789 | context "where integer column is not in the set (1, 8, 729)" do
790 | setup do
791 | @s.int_not_in = [1, 8, 729]
792 | end
793 |
794 | should "return six results" do
795 | assert_equal 6, @s.all.size
796 | end
797 |
798 | should "not contain results outside the specified set" do
799 | assert_equal [], @s.all.reject {|r|
800 | ![1, 8, 729].include?(r.int)
801 | }
802 | end
803 | end
804 | end
805 | end
806 |
807 | context_a_search_against "a relation with existing criteria and joins",
808 | Company.where(:name => "Initech").joins(:developers) do
809 | should "return the same results as a non-searched relation with no search terms" do
810 | assert_equal Company.where(:name => "Initech").joins(:developers).all, @s.all
811 | end
812 |
813 | context "with a search against the joined association's data" do
814 | setup do
815 | @s.developers_salary_less_than = 75000
816 | end
817 |
818 | should "not ask to join the association twice" do
819 | assert_equal 1, @s.relation.joins_values.size
820 | end
821 |
822 | should "return a filtered result set based on the criteria of the searched relation" do
823 | assert_equal Company.where(:name => 'Initech').all, @s.all.uniq
824 | end
825 | end
826 | end
827 |
828 | context_a_search_against "a relation derived from a joined association",
829 | Company.where(:name => "Initech").first.developers do
830 | should "not raise an error" do
831 | assert_nothing_raised do
832 | @s.all
833 | end
834 | end
835 |
836 | should "return all developers for that company without conditions" do
837 | assert_equal Company.where(:name => 'Initech').first.developers.all, @s.all
838 | end
839 |
840 | should "allow conditions on the search" do
841 | @s.name_equals = 'Peter Gibbons'
842 | assert_equal Developer.where(:name => 'Peter Gibbons').first,
843 | @s.first
844 | end
845 | end
846 |
847 | context_a_search_against "a relation derived from a joined HM:T association",
848 | Company.where(:name => "Initech").first.developer_notes do
849 | should "not raise an error" do
850 | assert_nothing_raised do
851 | @s.all
852 | end
853 | end
854 |
855 | should "return all developer notes for that company without conditions" do
856 | assert_equal Company.where(:name => 'Initech').first.developer_notes.all, @s.all
857 | end
858 |
859 | should "allow conditions on the search" do
860 | @s.note_equals = 'A straight shooter with upper management written all over him.'
861 | assert_equal Note.where(:note => 'A straight shooter with upper management written all over him.').first,
862 | @s.first
863 | end
864 | end
865 |
866 | [{:name => 'Project', :object => Project},
867 | {:name => 'Project as a Relation', :object => Project.scoped}].each do |object|
868 | context_a_search_against object[:name], object[:object] do
869 | context "where name is present" do
870 | setup do
871 | @s.name_is_present = true
872 | end
873 |
874 | should "return 5 results" do
875 | assert_equal 5, @s.all.size
876 | end
877 |
878 | should "contain no results with a blank name column" do
879 | assert_equal 0, @s.all.select {|r| r.name.blank?}.size
880 | end
881 | end
882 |
883 | context "where name is blank" do
884 | setup do
885 | @s.name_is_blank = true
886 | end
887 |
888 | should "return 2 results" do
889 | assert_equal 2, @s.all.size
890 | end
891 |
892 | should "contain no results with a present name column" do
893 | assert_equal 0, @s.all.select {|r| r.name.present?}.size
894 | end
895 | end
896 |
897 | context "where name is null" do
898 | setup do
899 | @s.name_is_null = true
900 | end
901 |
902 | should "return 1 result" do
903 | assert_equal 1, @s.all.size
904 | end
905 |
906 | should "contain no results with a non-null name column" do
907 | assert_equal 0, @s.all.select {|r| r.name != nil}.size
908 | end
909 | end
910 |
911 | context "where name is not null" do
912 | setup do
913 | @s.name_is_not_null = true
914 | end
915 |
916 | should "return 6 results" do
917 | assert_equal 6, @s.all.size
918 | end
919 |
920 | should "contain no results with a null name column" do
921 | assert_equal 0, @s.all.select {|r| r.name = nil}.size
922 | end
923 | end
924 |
925 | context "where notes_id is null" do
926 | setup do
927 | @s.notes_id_is_null = true
928 | end
929 |
930 | should "return 2 results" do
931 | assert_equal 2, @s.all.size
932 | end
933 |
934 | should "contain no results with notes" do
935 | assert_equal 0, @s.all.select {|r| r.notes.size > 0}.size
936 | end
937 | end
938 | end
939 | end
940 |
941 | [{:name => 'Note', :object => Note},
942 | {:name => 'Note as a Relation', :object => Note.scoped}].each do |object|
943 | context_a_search_against object[:name], object[:object] do
944 | should "allow search on polymorphic belongs_to associations" do
945 | @s.notable_project_type_name_contains = 'MetaSearch'
946 | assert_equal Project.find_by_name('MetaSearch Development').notes, @s.all
947 | end
948 |
949 | should "allow search on multiple polymorphic belongs_to associations" do
950 | @s.notable_project_type_name_or_notable_developer_type_name_starts_with = 'M'
951 | assert_equal Project.find_by_name('MetaSearch Development').notes +
952 | Developer.find_by_name('Michael Bolton').notes,
953 | @s.all
954 | end
955 |
956 | should "allow traversal of polymorphic associations" do
957 | @s.notable_developer_type_company_name_starts_with = 'M'
958 | assert_equal Company.find_by_name('Mission Data').developers.map(&:notes).flatten.sort {|a, b| a.id <=>b.id},
959 | @s.all.sort {|a, b| a.id <=> b.id}
960 | end
961 |
962 | should "raise an error when attempting to search against polymorphic belongs_to association without a type" do
963 | assert_raises ::MetaSearch::PolymorphicAssociationMissingTypeError do
964 | @s.notable_name_contains = 'MetaSearch'
965 | end
966 | end
967 | end
968 | end
969 | end
970 |
--------------------------------------------------------------------------------
/test/test_view_helpers.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 | require 'action_controller'
3 | require 'action_view/test_case'
4 |
5 | class TestViewHelpers < ActionView::TestCase
6 | tests MetaSearch::Helpers::FormHelper
7 | include MetaSearch::Helpers::UrlHelper
8 |
9 | def self.router
10 | @router ||= begin
11 | router = ActionDispatch::Routing::RouteSet.new
12 | router.draw do
13 | resources :developers
14 | resources :companies
15 | resources :projects
16 | resources :notes
17 | match ':controller(/:action(/:id(.:format)))'
18 | end
19 | router
20 | end
21 | end
22 |
23 | include router.url_helpers
24 |
25 | # FIXME: figure out a cleaner way to get this behavior
26 | def setup
27 | router = self.class.router
28 | @controller = ActionView::TestCase::TestController.new
29 | @controller.instance_variable_set(:@_routes, router)
30 | @controller.class_eval do
31 | include router.url_helpers
32 | end
33 |
34 | @controller.view_context_class.class_eval do
35 | include router.url_helpers
36 | end
37 | end
38 |
39 | context "A search against Company and a search against Developer" do
40 | setup do
41 | @s1 = Company.search
42 | @s2 = Developer.search
43 | form_for @s1 do |f|
44 | @f1 = f
45 | end
46 |
47 | form_for @s2 do |f|
48 | @f2 = f
49 | end
50 | end
51 |
52 | should "use the default localization for predicates" do
53 | assert_match /Name isn't null/, @f1.label(:name_is_not_null)
54 | end
55 |
56 | context "in the Flanders locale" do
57 | setup do
58 | I18n.locale = :flanders
59 | end
60 |
61 | teardown do
62 | I18n.locale = nil
63 | end
64 |
65 | should "localize according to their bases" do
66 | assert_match /Company name-diddly contains-diddly/, @f1.label(:name_contains)
67 | assert_match /Company reverse name-diddly/, @f1.label(:reverse_name)
68 | assert_match /Developer name-diddly contains-aroonie/, @f2.label(:name_like)
69 | end
70 |
71 | should "localize more than one attribute when joined with or" do
72 | assert_match /Developer name-diddly or-diddly Developer salary-doodly equals-diddly/, @f2.label(:name_or_salary_eq)
73 | end
74 | end
75 | end
76 |
77 | context "A previously-filled search form" do
78 | setup do
79 | @s = Company.search
80 | @s.created_at_gte = [2001, 2, 3, 4, 5]
81 | @s.name_contains = "bacon"
82 | form_for @s do |f|
83 | @f = f
84 | end
85 | end
86 |
87 | should "retain previous search terms" do
88 | html = @f.datetime_select(:created_at_gte)
89 | ['2001', '3', '04', '05'].each do |v|
90 | assert_match /