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

How many heads?

304 | 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 | #

How many heads?

78 | # 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 | #

How many heads?

94 | #

95 | # <% f.checks(:number_of_heads_in, 96 | # [['One', 1], ['Two', 2], ['Three', 3]], 97 | # :class => 'checkboxy').in_groups_of(2, false) do |checks| %> 98 | # <% checks.each do |check| %> 99 | # <%= check.box %> 100 | # <%= check.label %> 101 | # <% end %> 102 | #
103 | # <% end %> 104 | #

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 /