├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib ├── lupa.rb └── lupa │ ├── scope_methods.rb │ ├── search.rb │ └── version.rb ├── lupa.gemspec ├── lupa.png └── test ├── array_search_test.rb ├── composition_test.rb ├── default_scope_search_test.rb ├── default_search_attributes_search_test.rb └── test_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | *.bundle 19 | *.so 20 | *.o 21 | *.a 22 | mkmf.log 23 | .ruby-gemset 24 | .ruby-version 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - 2.0.0 5 | - 2.1.5 6 | - 2.2.1 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.0.1 2 | 3 | * enhancements 4 | * A **Lupa::DefaultSearchAttributesError** exception will be raised if `default_search_attributes` does not return a hash. 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in lupa.gemspec 4 | gemspec 5 | 6 | gem 'coveralls', require: false 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Ezequiel Delpero 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](lupa.png) 2 | 3 | Lupa means *Magnifier* in spanish. 4 | 5 | [![Build Status](https://travis-ci.org/edelpero/lupa.svg?branch=master)](https://travis-ci.org/edelpero/lupa) [![Coverage Status](https://coveralls.io/repos/edelpero/lupa/badge.svg?branch=master)](https://coveralls.io/r/edelpero/lupa?branch=master) [![Code Climate](https://codeclimate.com/github/edelpero/lupa/badges/gpa.svg)](https://codeclimate.com/github/edelpero/lupa) [![Inline docs](http://inch-ci.org/github/edelpero/lupa.svg?branch=master)](http://inch-ci.org/github/edelpero/lupa) 6 | 7 | Lupa lets you create simple, robust and scaleable search filters with ease using regular Ruby classes and object oriented design patterns. 8 | 9 | Lupa is Framework and ORM agnostic. It will work with any ORM or Object that can build a query using **chained method calls**, like ActiveRecord: ` 10 | Product.where(name: 'Digital').where(category: '23').limit(2)`. 11 | 12 | **Table of Contents:** 13 | 14 | * [Search Class](#search-class) 15 | * [Overview](#overview) 16 | * [Definition](#definition) 17 | * [Public Methods](#public-methods) 18 | * [Default Search Scope](#default-search-scope) 19 | * [Default Search Attributes](#default-search-attributes) 20 | * [Combining Search Classes](#combining-search-classes) 21 | * [Usage with Rails](#usage-with-rails) 22 | * [Testing](#testing) 23 | * [Testing Default Scope](#testing-default-scope) 24 | * [Testing Default Search Attributes](#testing-default-search-attributes) 25 | * [Testing Each Scope Method Individually](#testing-each-scope-method-individually) 26 | * [Benchmarks](#benchmarks) 27 | * [Lupa vs HasScope](#lupa-vs-hasscope) 28 | * [Lupa vs Searchlight](#lupa-vs-searchlight) 29 | * [Installation](#installation) 30 | 31 | 32 | ## Search Class 33 | 34 | ### Overview 35 | 36 | ```ruby 37 | products = ProductSearch.new(current_user.products).search(name: 'digital', category: '23') 38 | 39 | # Iterate over the search results 40 | products.each do |product| 41 | # Your logic goes here 42 | end 43 | ``` 44 | Calling **.each** on the instance will build a search by chaining calls to **name** and **category** methods defined in our **ProductSearch::Scope** class. 45 | 46 | ```ruby 47 | # app/searches/product_search.rb 48 | 49 | class ProductSearch < Lupa::Search 50 | # Scope class holds all your search methods. 51 | class Scope 52 | 53 | # Search method 54 | def name 55 | scope.where('name iLIKE ?', "%#{search_attributes[:name]}%") 56 | end 57 | 58 | # Search method 59 | def category 60 | scope.where(category_id: search_attributes[:category]) 61 | end 62 | 63 | end 64 | end 65 | ``` 66 | 67 | ### Definition 68 | To define a search class, your class must inherit from **Lupa::Search** and you must define a **Scope** class inside your search class. 69 | 70 | ```ruby 71 | # app/searches/product_search.rb 72 | 73 | class ProductSearch < Lupa::Search 74 | class Scope 75 | end 76 | end 77 | ``` 78 | Inside your **Scope** class you must define your scope methods. You'll also be able to access to the following methods inside your scope class: **scope** and **search_attributes**. 79 | 80 | * **`scope:`** returns the current scope when the scope method is called. 81 | * **`search_attributes:`** returns a hash containing the all search attributes specified including the default ones. 82 | 83 | **Note:** All keys of **`search_attributes`** are symbolized. 84 | 85 | ```ruby 86 | # app/searches/product_search.rb 87 | 88 | class ProductSearch < Lupa::Search 89 | # Scope class holds all your search methods. 90 | class Scope 91 | 92 | # Search method 93 | def name 94 | scope.where('name LIKE ?', "%#{search_attributes[:name]}%") 95 | end 96 | 97 | # Search method 98 | def category 99 | scope.where(category_id: search_attributes[:category]) 100 | end 101 | 102 | end 103 | end 104 | ``` 105 | The scope methods specified on the search params will be the only ones applied to the scope. Search params keys must always match the Scope class methods names. 106 | 107 | ### Public Methods 108 | 109 | Your search class has the following public methods: 110 | 111 | - **`scope:`** returns the scope to which all search rules will be applied. 112 | 113 | ```ruby 114 | search = ProductSearch.new(current_user.products).search(name: 'chair', category: '23') 115 | search.scope 116 | 117 | # => current_user.products 118 | ``` 119 | 120 | - **`search_attributes:`** returns a hash with all search attributes including default search attributes. 121 | 122 | ```ruby 123 | search = ProductSearch.new(current_user.products).search(name: 'chair', category: '23') 124 | search.search_attributes 125 | 126 | # => { name: 'chair', category: '23' } 127 | ``` 128 | - **`default_search_attributes:`** returns a hash with default search attributes. A more detailed explanation about default search attributes can be found below this section. 129 | 130 | - **`results:`** returns the resulting scope after all searching rules have been applied. 131 | 132 | ```ruby 133 | search = ProductSearch.new(current_user.products).search(name: 'chair', category: '23') 134 | search.results 135 | 136 | # => # 137 | ``` 138 | 139 | - **OTHER METHODS** applied to your search class will result in calling to **`results`** and applying that method to the resulting scope. If the resulting scope doesn't respond to the method, an exception will be raised. 140 | 141 | ```ruby 142 | search = ProductSearch.new(current_user.products).search(name: 'chair', category: '23') 143 | 144 | search.first 145 | # => # 146 | 147 | search.unexisting_method 148 | # => Lupa::ResultMethodNotImplementedError: The resulting scope does not respond to unexisting_method method. 149 | ``` 150 | 151 | ### Default Search Scope 152 | 153 | You can define a default search scope if you want to use a search class with an specific resource by overriding the initialize method as follows: 154 | 155 | ```ruby 156 | # app/searches/product_search.rb 157 | 158 | class ProductSearch < Lupa::Search 159 | class Scope 160 | ... 161 | end 162 | 163 | # Be careful not to change the scope variable name, 164 | # otherwise you will experience issues. 165 | def initialize(scope = Product.all) 166 | @scope = scope 167 | end 168 | end 169 | ``` 170 | 171 | Then you can use your search class without passing the scope: 172 | 173 | ```ruby 174 | search = ProductSearch.search(name: 'chair', category: '23') 175 | 176 | search.first 177 | # => # 178 | ``` 179 | 180 | ### Default Search Attributes 181 | 182 | Defining default search attributes will cause the scope method to be invoked always. 183 | 184 | ```ruby 185 | # app/searches/product_search.rb 186 | 187 | class ProductSearch < Lupa::Search 188 | class Scope 189 | ... 190 | end 191 | 192 | # This should always return a hash 193 | def default_search_attributes 194 | { category: '23' } 195 | end 196 | end 197 | ``` 198 | 199 | ```ruby 200 | search = ProductSearch.new(current_user.products).search(name: 'chair') 201 | 202 | search.search_attributes 203 | # => { name: 'chair', category: '23' } 204 | ``` 205 | 206 | **Note:** You can override default search attributes by passing it to the search params. 207 | 208 | ``` ruby 209 | search = ProductSearch.new(current_user.products).search(name: 'chair', category: '42') 210 | 211 | search.search_attributes 212 | # => { name: 'chair', category: '42' } 213 | ``` 214 | 215 | ### Combining Search Classes 216 | 217 | You can reuse your search class in order to keep them DRY. 218 | 219 | A common example is searching records created between two dates. So lets create a **CreatedAtSearch** class to handle that logic. 220 | 221 | ```ruby 222 | # app/searches/created_between_search.rb 223 | 224 | class CreatedAtSearch < Lupa::Search 225 | class Scope 226 | 227 | def created_before 228 | ... 229 | end 230 | 231 | def created_after 232 | ... 233 | end 234 | 235 | def created_between 236 | if created_start_date && created_end_date 237 | scope.where(created_at: created_start_date..created_end_date) 238 | end 239 | end 240 | 241 | private 242 | 243 | # Parses search_attributes[:created_between][:start_date] 244 | def created_start_date 245 | search_attributes[:created_between] && 246 | search_attributes[:created_between][:start_date].try(:to_date) 247 | end 248 | 249 | # Parses search_attributes[:created_between][:end_date] 250 | def created_end_date 251 | search_attributes[:created_between] && 252 | search_attributes[:created_between][:end_date].try(:to_date) 253 | end 254 | end 255 | end 256 | ``` 257 | 258 | Now we can use it in our **ProductSearch** class: 259 | 260 | ```ruby 261 | # app/searches/product_search.rb 262 | 263 | class ProductSearch < Lupa::Search 264 | class Scope 265 | 266 | def name 267 | ... 268 | end 269 | 270 | # We use CreatedAtSearch class to perform the search. 271 | # Be sure to always call `results` method on your composed 272 | # search class. 273 | def created_between 274 | CreatedAtSearch.new(scope). 275 | search(created_between: search_attributes[:created_between]). 276 | results 277 | end 278 | 279 | def category 280 | ... 281 | end 282 | 283 | end 284 | end 285 | ``` 286 | **Note:** If you are combining search classes. Be sure to always call **results** method on the search classes composing your main search class. 287 | 288 | ## Usage with Rails 289 | 290 | ### Forms 291 | 292 | Define a custom form: 293 | 294 | ```haml 295 | # app/views/products/_search.html.haml 296 | 297 | = form_tag products_path, method: :get do 298 | = text_field_tag 'name' 299 | = select_tag 'category', options_from_collection_for_select(@categories, 'id', 'name') 300 | = date_field_tag 'created_between[start_date]' 301 | = date_field_tag 'created_between[end_date]' 302 | = submit_tag :search 303 | ``` 304 | 305 | ### Controllers 306 | 307 | Create a new instance of your search class and pass a collection to which all search conditions will be applied and specify the search params you want to apply: 308 | 309 | ```ruby 310 | # app/controllers/products_controller.rb 311 | 312 | class ProductsController < ApplicationController 313 | def index 314 | @products = ProductSearch.new(current_user.products).search(search_params) 315 | end 316 | 317 | protected 318 | def search_params 319 | params.permit(:name, :category, created_between: [:start_date, :end_date]) 320 | end 321 | end 322 | ``` 323 | ### Views 324 | 325 | Loop through the search results on your view. 326 | 327 | ```haml 328 | # app/views/products/index.html.haml 329 | 330 | %h1 Products 331 | 332 | %ul 333 | - @products.each do |product| 334 | %li 335 | = "#{product.name} - #{product.price} - #{product.category}" 336 | ``` 337 | 338 | ## Testing 339 | 340 | This is a list of things you should test when creating a search class: 341 | 342 | - **Default Scope** if specified. 343 | - **Default Search Attributes** if specified. 344 | - **Each Scope Method** individually. 345 | 346 | ### Testing Default Scope 347 | 348 | ```ruby 349 | # app/searches/product_search.rb 350 | 351 | class ProductSearch < Lupa::Search 352 | class Scope 353 | ... 354 | end 355 | 356 | def initialize(scope = Product.all) 357 | @scope = scope 358 | end 359 | end 360 | ``` 361 | 362 | ```ruby 363 | # test/searches/product_search_test.rb 364 | require 'test_helper' 365 | 366 | describe ProductSearch do 367 | describe 'Default Scope' do 368 | context 'when not passing a scope to search initializer and no search params' do 369 | it 'returns default scope' do 370 | results = ProductSearch.search({}).results 371 | results.must_equal Product.all 372 | end 373 | end 374 | end 375 | end 376 | ``` 377 | 378 | ### Testing Default Search Attributes 379 | 380 | ```ruby 381 | # app/searches/product_search.rb 382 | 383 | class ProductSearch < Lupa::Search 384 | class Scope 385 | ... 386 | end 387 | 388 | def initialize(scope = Product.all) 389 | @scope = scope 390 | end 391 | 392 | def default_search_attributes 393 | { category: '23' } 394 | end 395 | end 396 | ``` 397 | 398 | ```ruby 399 | # test/searches/product_search_test.rb 400 | require 'test_helper' 401 | 402 | describe ProductSearch do 403 | describe 'Default Search Attributes' do 404 | context 'when not overriding default_search_attributes' do 405 | it 'returns default default_search_attributes' do 406 | default_search_attributes = { category: 23 } 407 | search = ProductSearch.search({}) 408 | search.default_search_attributes.must_equal default_search_attributes 409 | end 410 | end 411 | end 412 | end 413 | ``` 414 | 415 | ### Testing Each Scope Method Individually 416 | 417 | ```ruby 418 | # app/searches/product_search.rb 419 | 420 | class ProductSearch < Lupa::Search 421 | class Scope 422 | def category 423 | scope.where(category_id: search_attributes[:category]) 424 | end 425 | 426 | def name 427 | ... 428 | end 429 | end 430 | 431 | def initialize(scope = Product.all) 432 | @scope = scope 433 | end 434 | end 435 | ``` 436 | 437 | ```ruby 438 | # test/searches/product_search_test.rb 439 | 440 | require 'test_helper' 441 | 442 | describe ProductSearch do 443 | describe 'Scopes' do 444 | 445 | describe '#category' do 446 | it 'returns products from specified category' do 447 | results = ProductSearch.search(category: '23').results 448 | results.must_equal Product.where(category_id: '23') 449 | end 450 | end 451 | 452 | describe '#name' do 453 | it 'returns products that contain specified letters' do 454 | ... 455 | end 456 | end 457 | 458 | end 459 | end 460 | ``` 461 | 462 | ## Benchmarks 463 | 464 | I used [benchmark-ips](https://github.com/evanphx/benchmark-ips). 465 | 466 | ### Lupa vs. [HasScope](https://github.com/plataformatec/has_scope) 467 | 468 | ``` 469 | Calculating ------------------------------------- 470 | lupa 265.000 i/100ms 471 | has_scope 254.000 i/100ms 472 | ------------------------------------------------- 473 | lupa 3.526k (±24.7%) i/s - 67.045k 474 | has_scope 3.252k (±24.8%) i/s - 61.976k 475 | 476 | Comparison: 477 | lupa: 3525.8 i/s 478 | has_scope: 3252.0 i/s - 1.08x slower 479 | ``` 480 | 481 | ### Lupa vs. [Searchlight](https://github.com/nathanl/searchlight) 482 | 483 | ``` 484 | Calculating ------------------------------------- 485 | lupa 480.000 i/100ms 486 | searchlight 232.000 i/100ms 487 | ------------------------------------------------- 488 | lupa 7.273k (±25.1%) i/s - 689.280k 489 | searchlight 2.665k (±14.1%) i/s - 260.072k 490 | 491 | Comparison: 492 | lupa: 7273.5 i/s 493 | searchlight: 2665.4 i/s - 2.73x slower 494 | ``` 495 | 496 | *If you know about another gem that was not included on the benchmark, feel free to run the benchmarks and send a Pull Request.* 497 | 498 | ## Installation 499 | 500 | Add this line to your application's Gemfile: 501 | 502 | gem 'lupa' 503 | 504 | And then execute: 505 | 506 | $ bundle 507 | 508 | Or install it yourself as: 509 | 510 | $ gem install lupa 511 | 512 | 513 | ## Contributing 514 | 515 | 1. Fork it ( https://github.com/edelpero/lupa/fork ) 516 | 2. Create your feature branch (`git checkout -b my-new-feature`) 517 | 3. Commit your changes (`git commit -am 'Add some feature'`) 518 | 4. Push to the branch (`git push origin my-new-feature`) 519 | 5. Create a new Pull Request 520 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | 3 | require 'rake/testtask' 4 | 5 | Rake::TestTask.new do |t| 6 | t.libs << 'test' 7 | t.test_files = FileList['test/*_test.rb'] 8 | t.verbose = true 9 | end 10 | 11 | task :default => :test 12 | -------------------------------------------------------------------------------- /lib/lupa.rb: -------------------------------------------------------------------------------- 1 | require "lupa/version" 2 | 3 | module Lupa 4 | DefaultScopeError = Class.new(StandardError) 5 | DefaultSearchAttributesError = Class.new(StandardError) 6 | ScopeMethodNotImplementedError = Class.new(NotImplementedError) 7 | ResultMethodNotImplementedError = Class.new(NotImplementedError) 8 | SearchAttributesError = Class.new(StandardError) 9 | end 10 | 11 | require "lupa/scope_methods" 12 | require "lupa/search" 13 | -------------------------------------------------------------------------------- /lib/lupa/scope_methods.rb: -------------------------------------------------------------------------------- 1 | module Lupa 2 | module ScopeMethods 3 | 4 | attr_accessor :scope 5 | attr_reader :search_attributes 6 | 7 | def initialize(scope, search_attributes) 8 | @scope = scope 9 | @search_attributes = search_attributes 10 | end 11 | 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/lupa/search.rb: -------------------------------------------------------------------------------- 1 | module Lupa 2 | class Search 3 | class Scope; end 4 | 5 | # Public: Return class scope. 6 | # 7 | # === Examples 8 | # 9 | # class ProductSearch < Lupa::Search 10 | # 11 | # class Scope 12 | # 13 | # def category 14 | # scope.where(category: search_attributes[:category]) 15 | # end 16 | # 17 | # def in_stock 18 | # scope.where(in_stock: search_attributes[:in_stock]) 19 | # end 20 | # 21 | # end 22 | # 23 | # def default_search_attributes 24 | # { in_stock: true } 25 | # end 26 | # 27 | # end 28 | # 29 | # search = ProductSearch.new(@products).search({ category: 'furniture' }) 30 | # search.scope 31 | # # => @products 32 | # 33 | # Returns your class scope. 34 | attr_reader :scope 35 | 36 | # Public: Return class search attributes including default search attributes. 37 | # 38 | # === Examples 39 | # 40 | # class ProductSearch < Lupa::Search 41 | # 42 | # class Scope 43 | # 44 | # def category 45 | # scope.where(category: search_attributes[:category]) 46 | # end 47 | # 48 | # def in_stock 49 | # scope.where(in_stock: search_attributes[:in_stock]) 50 | # end 51 | # 52 | # end 53 | # 54 | # def default_search_attributes 55 | # { in_stock: true } 56 | # end 57 | # 58 | # end 59 | # 60 | # search = ProductSearch.new(@products).search({ category: 'furniture' }) 61 | # search.search_attributes 62 | # # => { category: furniture, in_stock: true } 63 | # 64 | # Returns your class search attributes including default search attributes. 65 | attr_reader :search_attributes 66 | 67 | # Public: Create a new instance of the class. 68 | # 69 | # === Options 70 | # 71 | # scope - An object which will be use to perform all the search operations. 72 | # 73 | # === Examples 74 | # 75 | # class ProductSearch < Lupa::Search 76 | # 77 | # class Scope 78 | # 79 | # def category 80 | # scope.where(category: search_attributes[:category]) 81 | # end 82 | # 83 | # def in_stock 84 | # scope.where(in_stock: search_attributes[:in_stock]) 85 | # end 86 | # 87 | # end 88 | # 89 | # def default_search_attributes 90 | # { in_stock: true } 91 | # end 92 | # 93 | # end 94 | # 95 | # scope = Product.where(price: 20..30) 96 | # search = ProductSearch.new(scope) 97 | # 98 | # Returns a new instance of the class. 99 | def initialize(scope) 100 | @scope = scope 101 | end 102 | 103 | # Public: Return default a hash containing default search attributes of the class. 104 | # 105 | # === Examples 106 | # 107 | # class ProductSearch < Lupa::Search 108 | # 109 | # class Scope 110 | # 111 | # def category 112 | # scope.where(category: search_attributes[:category]) 113 | # end 114 | # 115 | # def in_stock 116 | # scope.where(in_stock: search_attributes[:in_stock]) 117 | # end 118 | # 119 | # end 120 | # 121 | # def default_search_attributes 122 | # { in_stock: true } 123 | # end 124 | # 125 | # end 126 | # 127 | # scope = Product.where(price: 20..30) 128 | # search = ProductSearch.new(scope).search({ category: 'furniture' }) 129 | # search.default_search_attributes 130 | # # => { in_stock: true } 131 | # 132 | # Returns default a hash containing default search attributes of the class. 133 | def default_search_attributes 134 | {} 135 | end 136 | 137 | # Public: Set and checks search attributes, and instantiates the Scope class. 138 | # 139 | # === Options 140 | # 141 | # attributes - The hash containing the search attributes. 142 | # 143 | # * If attributes is not a Hash kind of class, it will raise a 144 | # Lupa::SearchAttributesError. 145 | # * If attributes keys don't match methods 146 | # defined on your class, it will raise a Lupa::NotImplementedError. 147 | # 148 | # === Examples 149 | # 150 | # class ProductSearch < Lupa::Search 151 | # 152 | # class Scope 153 | # 154 | # def category 155 | # scope.where(category: search_attributes[:category]) 156 | # end 157 | # 158 | # end 159 | # 160 | # end 161 | # 162 | # scope = Product.where(price: 20..30) 163 | # search = ProductSearch.new(scope).search({ category: 'furniture' }) 164 | # # => #'furniture'}, @scope_class=#true}>> 165 | # 166 | # Returns the class instance itself. 167 | def search(attributes) 168 | raise Lupa::SearchAttributesError, "Your search params needs to be a hash." unless attributes.respond_to?(:keys) 169 | 170 | set_search_attributes(attributes) 171 | set_scope_class 172 | check_method_definitions 173 | self 174 | end 175 | 176 | # Public: Creates a new instance of the search class an applies search method with attributes to it. 177 | # 178 | # === Options 179 | # 180 | # attributes - The hash containing the search attributes. 181 | # 182 | # * If search class doesn't have a default scope specified, it will raise a 183 | # Lupa::DefaultScopeError exception. 184 | # * If attributes is not a Hash kind of class, it will raise a 185 | # Lupa::SearchAttributesError exception. 186 | # * If attributes keys don't match methods 187 | # defined on your class, it will raise a Lupa::NotImplementedError. 188 | # 189 | # === Examples 190 | # 191 | # class ProductSearch < Lupa::Search 192 | # 193 | # class Scope 194 | # 195 | # def category 196 | # scope.where(category: search_attributes[:category]) 197 | # end 198 | # 199 | # end 200 | # 201 | # def initialize(scope = Product.in_stock) 202 | # @scope = scope 203 | # end 204 | # 205 | # end 206 | # 207 | # search = ProductSearch.search({ category: 'furniture' }) 208 | # # => #'furniture'}, @scope_class=#true}>> 209 | # 210 | # Returns the class instance itself. 211 | def self.search(attributes) 212 | new.search(attributes) 213 | rescue ArgumentError 214 | raise Lupa::DefaultScopeError, "You need to define a default scope in order to user search class method." 215 | end 216 | 217 | # Public: Return the search result. 218 | # 219 | # === Examples 220 | # 221 | # class ProductSearch < Lupa::Search 222 | # 223 | # class Scope 224 | # 225 | # def category 226 | # scope.where(category: search_attributes[:category]) 227 | # end 228 | # 229 | # end 230 | # 231 | # def initialize(scope = Product.in_stock) 232 | # @scope = scope 233 | # end 234 | # 235 | # end 236 | # 237 | # search = ProductSearch.search({ category: 'furniture' }).results 238 | # # => # 239 | # 240 | # Returns the search result. 241 | def results 242 | @results ||= run 243 | end 244 | 245 | # Public: Apply the missing method to the search result. 246 | # 247 | # === Examples 248 | # 249 | # class ProductSearch < Lupa::Search 250 | # 251 | # class Scope 252 | # 253 | # def category 254 | # scope.where(category: search_attributes[:category]) 255 | # end 256 | # 257 | # end 258 | # 259 | # def initialize(scope = Product.in_stock) 260 | # @scope = scope 261 | # end 262 | # 263 | # end 264 | # 265 | # search = ProductSearch.search({ category: 'furniture' }).first 266 | # # => # 267 | # 268 | # Returns the search result. 269 | def method_missing(method_sym, *arguments, &block) 270 | if results.respond_to?(method_sym) 271 | results.send(method_sym, *arguments, &block) 272 | else 273 | raise Lupa::ResultMethodNotImplementedError, "The resulting scope does not respond to #{method_sym} method." 274 | end 275 | end 276 | 277 | private 278 | # Internal: Store the scope class. 279 | # 280 | # Stores the scope class. 281 | attr_accessor :scope_class 282 | 283 | # Internal: Set @search_attributes by merging default search attributes with the ones passed to search method. 284 | # 285 | # === Options 286 | # 287 | # attributes - The hash containing the search attributes. 288 | # 289 | # === Examples 290 | # 291 | # class ProductSearch < Lupa::Search 292 | # 293 | # class Scope 294 | # 295 | # def category 296 | # scope.where(category: search_attributes[:category]) 297 | # end 298 | # 299 | # def in_stock 300 | # scope.where(in_stock: search_attributes[:in_stock]) 301 | # end 302 | # 303 | # end 304 | # 305 | # def default_search_attributes 306 | # { in_stock: true } 307 | # end 308 | # 309 | # scope = Product.where(in_warehouse: true) 310 | # search = ProductSearch.new(scope).search(category: 'furniture') 311 | # 312 | # set_search_attributes(category: 'furniture') 313 | # # => { category: 'furniture', in_stock: true } 314 | # 315 | # Sets @search_attributes by merging default search attributes with the ones passed to search method. 316 | def set_search_attributes(attributes) 317 | attributes = merge_search_attributes(attributes) 318 | attributes = symbolize_keys(attributes) 319 | attributes = remove_blank_attributes(attributes) 320 | 321 | @search_attributes = attributes 322 | end 323 | 324 | # Internal: Merge search attributes with default search attributes 325 | def merge_search_attributes(attributes) 326 | return default_search_attributes.merge(attributes) if default_search_attributes.kind_of?(Hash) 327 | 328 | raise Lupa::DefaultSearchAttributesError, "default_search_attributes doesn't return a Hash." 329 | end 330 | 331 | # Internal: Symbolizes all keys passed to the search attributes. 332 | def symbolize_keys(attributes) 333 | return attributes.reduce({}) do |attribute, (key, value)| 334 | attribute.tap { |a| a[key.to_sym] = symbolize_keys(value) } 335 | end if attributes.is_a? Hash 336 | 337 | return attributes.reduce([]) do |attribute, value| 338 | attribute << symbolize_keys(value); attribute 339 | end if attributes.is_a? Array 340 | 341 | attributes 342 | end 343 | 344 | # Internal: Removes all empty values passed to search attributes. 345 | def remove_blank_attributes(attributes) 346 | attributes.delete_if { |key, value| clean_attribute(value) } 347 | end 348 | 349 | # Internal: Iterates over value child attributes to remove empty values. 350 | def clean_attribute(value) 351 | if value.kind_of?(Hash) 352 | value.delete_if { |key, value| clean_attribute(value) }.empty? 353 | elsif value.kind_of?(Array) 354 | value.delete_if { |value| clean_attribute(value) }.empty? 355 | else 356 | value.to_s.strip.empty? 357 | end 358 | end 359 | 360 | # Internal: Includes ScopeMethods module into the Scope class and instantiate it. 361 | def set_scope_class 362 | klass = self.class::Scope 363 | klass.send(:include, ScopeMethods) 364 | @scope_class = klass.new(@scope, @search_attributes) 365 | end 366 | 367 | # Internal: Check for search methods to be correctly defined using search attributes. 368 | # 369 | # * If you pass a search attribute that doesn't exist and your Scope class 370 | # doesn't have that method defined a Lupa::ScopeMethodNotImplementedError 371 | # exception will be raised. 372 | def check_method_definitions 373 | method_names = search_attributes.keys 374 | 375 | method_names.each do |method_name| 376 | next if scope_class.respond_to?(method_name) 377 | raise Lupa::ScopeMethodNotImplementedError, "#{method_name} is not defined on your #{self.class}::Scope class." 378 | end 379 | end 380 | 381 | # Internal: Applies search attributes keys as methods over the scope_class. 382 | # 383 | # * If search_attributes are not specified a Lupa::SearchAttributesError 384 | # exception will be raised. 385 | # 386 | # Returns the result of the search. 387 | def run 388 | raise Lupa::SearchAttributesError, "You need to specify search attributes." unless search_attributes 389 | 390 | search_attributes.each do |method_name, value| 391 | new_scope = scope_class.public_send(method_name) 392 | scope_class.scope = new_scope unless new_scope.nil? 393 | end 394 | 395 | scope_class.scope 396 | end 397 | 398 | end 399 | end 400 | -------------------------------------------------------------------------------- /lib/lupa/version.rb: -------------------------------------------------------------------------------- 1 | module Lupa 2 | VERSION = "1.0.1" 3 | end 4 | -------------------------------------------------------------------------------- /lupa.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'lupa/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "lupa" 8 | spec.version = Lupa::VERSION 9 | spec.authors = ["Ezequiel Delpero"] 10 | spec.email = ["edelpero@gmail.com"] 11 | spec.summary = %q{Search Filters using Object Oriented Design.} 12 | spec.description = %q{Lupa lets you create simple, robust and scaleable search filters with ease using regular Ruby classes and object oriented design patterns.} 13 | spec.homepage = "https://github.com/edelpero/lupa" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "minitest", "~> 5.5.1" 22 | spec.add_development_dependency "bundler", "~> 1.6" 23 | spec.add_development_dependency "rake" 24 | end 25 | -------------------------------------------------------------------------------- /lupa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edelpero/lupa/bad996b25d1cfe7cbd4796b71fc5f456db70b60a/lupa.png -------------------------------------------------------------------------------- /test/array_search_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ArraySearch < Lupa::Search 4 | class Scope 5 | def even_numbers 6 | if search_attributes[:even_numbers] 7 | scope.collect { |number| number if number.even? }.compact 8 | else 9 | scope 10 | end 11 | end 12 | 13 | def reverse 14 | if search_attributes[:reverse] 15 | scope.reverse 16 | else 17 | scope 18 | end 19 | end 20 | end 21 | end 22 | 23 | describe Lupa::Search do 24 | before do 25 | @array = [1, 2, 3, 4, 5, 6, 7, 8] 26 | @search_attributes = { even_numbers: true } 27 | end 28 | 29 | describe '#search_attributes' do 30 | context 'when passing search params' do 31 | it 'returns search attributes' do 32 | search = ArraySearch.new(@array).search(@search_attributes) 33 | search.search_attributes.must_equal @search_attributes 34 | end 35 | end 36 | 37 | context 'when not passing search params' do 38 | it 'returns nil' do 39 | search = ArraySearch.new(@array) 40 | search.search_attributes.must_equal nil 41 | end 42 | end 43 | 44 | context 'when passing an empty hash to search params' do 45 | it 'returns an empty hash' do 46 | params = {} 47 | search = ArraySearch.new(@array).search(params) 48 | search.search_attributes.must_equal params 49 | end 50 | end 51 | 52 | context 'when passing search params with empty values' do 53 | it 'removes empty values from the search params' do 54 | params = { even_numbers: true, reverse: { one: '', two: '' }, blank: { an_array: [''] }} 55 | search = ArraySearch.new(@array).search(params) 56 | search.search_attributes.must_equal @search_attributes 57 | end 58 | end 59 | 60 | context 'when search params contains keys as strings' do 61 | it 'converts the strings into symbols' do 62 | params = { 'even_numbers' => true, reverse: { one: '', two: '' }, blank: { an_array: [''] }} 63 | search = ArraySearch.new(@array).search(params) 64 | search.search_attributes.must_equal @search_attributes 65 | end 66 | end 67 | 68 | context 'when passing another object rather than a hash to search params' do 69 | it 'raises a Lupa::SearchAttributesError' do 70 | proc { ArraySearch.new(@array).search(1) }.must_raise Lupa::SearchAttributesError 71 | end 72 | end 73 | end 74 | 75 | describe '#search' do 76 | context 'when passing valid params' do 77 | it 'sets search attributes' do 78 | search = ArraySearch.new(@array).search(@search_attributes) 79 | search.search_attributes.must_equal @search_attributes 80 | end 81 | end 82 | 83 | context 'when passing invalid params' do 84 | it 'raises a Lupa::ScopeMethodNotImplementedError' do 85 | params = { even_numbers: true, not_existing_search: 2 } 86 | proc { ArraySearch.new(@array).search(params) }.must_raise Lupa::ScopeMethodNotImplementedError 87 | end 88 | end 89 | end 90 | 91 | describe '#results' do 92 | context 'when passing search attributes' do 93 | it 'returns the search results' do 94 | search = ArraySearch.new(@array).search(@search_attributes) 95 | search.results.must_equal [2, 4, 6, 8] 96 | end 97 | end 98 | 99 | context 'when not passing search attributes' do 100 | it 'returns the default scope' do 101 | search = ArraySearch.new(@array) 102 | proc { search.results }.must_raise Lupa::SearchAttributesError 103 | end 104 | end 105 | 106 | context 'when passing multiple search attributes' do 107 | it 'returns the search results' do 108 | params = { even_numbers: true, reverse: true } 109 | search = ArraySearch.new(@array).search(params) 110 | search.results.must_equal [8, 6, 4, 2] 111 | end 112 | end 113 | end 114 | 115 | describe '#method_missing' do 116 | context 'when result respond to method' do 117 | it 'applies method to the resulting scope' do 118 | search = ArraySearch.new(@array).search(@search_attributes) 119 | search.first.must_equal 2 120 | end 121 | end 122 | 123 | context 'when result not respond to method' do 124 | it 'raises a Lupa::ResultMethodNotImplementedError exception' do 125 | search = ArraySearch.new(@array).search(@search_attributes) 126 | proc { search.not_existing_method }.must_raise Lupa::ResultMethodNotImplementedError 127 | end 128 | end 129 | end 130 | 131 | end 132 | -------------------------------------------------------------------------------- /test/composition_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ReverseSearch < Lupa::Search 4 | 5 | class Scope 6 | def reverse 7 | scope.reverse 8 | end 9 | end 10 | 11 | end 12 | 13 | class EvenSearch < Lupa::Search 14 | 15 | class Scope 16 | def even 17 | scope.map { |number| number if number.even? }.compact 18 | end 19 | 20 | def reverse 21 | ReverseSearch.new(scope).search(reverse: true) 22 | end 23 | end 24 | 25 | def initialize(scope = [1, 2, 3, 4]) 26 | @scope = scope 27 | end 28 | 29 | end 30 | 31 | 32 | describe Lupa::Search do 33 | 34 | describe 'Composition' do 35 | it 'calls another search class inside of it' do 36 | results = EvenSearch.search(even: true, reverse: true).results 37 | results.first.must_equal 4 38 | end 39 | end 40 | 41 | end 42 | -------------------------------------------------------------------------------- /test/default_scope_search_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ClassWithDefaultScopeSearch < Lupa::Search 4 | 5 | class Scope 6 | def reverse 7 | if search_attributes[:reverse] 8 | scope.reverse 9 | end 10 | end 11 | end 12 | 13 | def initialize(scope = [1, 2, 3, 4]) 14 | @scope = scope 15 | end 16 | 17 | end 18 | 19 | class ClassWithoutDefaultScopeSearch < Lupa::Search 20 | 21 | class Scope 22 | def reverse; end 23 | end 24 | 25 | end 26 | 27 | 28 | describe Lupa::Search do 29 | before do 30 | @array = [1, 2, 3, 4] 31 | @search_attributes = { reverse: true } 32 | end 33 | 34 | describe '.search' do 35 | context 'when class has a default scope' do 36 | context 'when passing search params' do 37 | it 'creates an instance of it class' do 38 | results = ClassWithDefaultScopeSearch.search(@search_attributes).results 39 | results.must_equal [4, 3, 2, 1] 40 | end 41 | end 42 | 43 | context 'when not passing search params' do 44 | it 'returns the default scope' do 45 | results = ClassWithDefaultScopeSearch.search({}).results 46 | results.must_equal [1, 2, 3, 4] 47 | end 48 | end 49 | end 50 | 51 | context 'when class does not have a default scope' do 52 | it 'raises a Lupa::DefaultScopeError exception' do 53 | proc { ClassWithoutDefaultScopeSearch.search(@search_attributes) }.must_raise Lupa::DefaultScopeError 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/default_search_attributes_search_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ClassWithDefaultSearchAttributesSearch < Lupa::Search 4 | 5 | class Scope 6 | def reverse 7 | if search_attributes[:reverse] 8 | scope.reverse 9 | end 10 | end 11 | end 12 | 13 | def initialize(scope = [1, 2, 3, 4]) 14 | @scope = scope 15 | end 16 | 17 | def default_search_attributes 18 | { reverse: true } 19 | end 20 | 21 | end 22 | 23 | class ClassWithoutDefaultSearchAttributesSearch < Lupa::Search 24 | 25 | class Scope 26 | def reverse; end 27 | end 28 | 29 | def initialize(scope = [1, 2, 3, 4]) 30 | @scope = scope 31 | end 32 | 33 | end 34 | 35 | class ClassWithInvalidDefaultSearchAttributesSearch < Lupa::Search 36 | 37 | class Scope 38 | def reverse; end 39 | end 40 | 41 | def initialize(scope = [1, 2, 3, 4]) 42 | @scope = scope 43 | end 44 | 45 | def default_search_attributes 46 | 1 47 | end 48 | 49 | end 50 | 51 | 52 | describe Lupa::Search do 53 | before do 54 | @default_search_attributes = { reverse: true } 55 | end 56 | 57 | describe '#default_search_attributes' do 58 | context 'when class has a default search attributes' do 59 | it 'returns a hash containing default search attributes' do 60 | search = ClassWithDefaultSearchAttributesSearch.search({}) 61 | search.default_search_attributes.must_equal @default_search_attributes 62 | end 63 | end 64 | 65 | context 'when overriding default search attributes' do 66 | it 'returns a hash with the default search attribute overwritten' do 67 | params = { reverse: false } 68 | search = ClassWithDefaultSearchAttributesSearch.search(params) 69 | search.search_attributes.must_equal params 70 | end 71 | end 72 | 73 | context 'when class does not have default search attributes' do 74 | it 'returns an empty hash' do 75 | params = {} 76 | search = ClassWithoutDefaultSearchAttributesSearch.search({}) 77 | search.default_search_attributes.must_equal params 78 | end 79 | end 80 | 81 | context 'when default_search_attributes does not return a Hash' do 82 | it 'raises a Lupa::DefaultSearchAttributesError exception' do 83 | proc { ClassWithInvalidDefaultSearchAttributesSearch.search({}).results }.must_raise Lupa::DefaultSearchAttributesError 84 | end 85 | end 86 | end 87 | 88 | describe '#results' do 89 | context 'when class has a default search attributes' do 90 | it 'applies default search methods to scope' do 91 | results = ClassWithDefaultSearchAttributesSearch.search({}).results 92 | results.must_equal [4, 3, 2, 1] 93 | end 94 | end 95 | 96 | context 'when class does not have default search attributes' do 97 | it 'does not applies default search methods to scope' do 98 | results = ClassWithoutDefaultSearchAttributesSearch.search({}).results 99 | results.must_equal [1, 2, 3, 4] 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'coveralls' 2 | Coveralls.wear! 3 | 4 | require 'minitest/autorun' 5 | 6 | def context(*args, &block) 7 | describe(*args, &block) 8 | end 9 | 10 | $:.unshift File.expand_path('../../lib', __FILE__) 11 | 12 | require 'lupa' 13 | 14 | --------------------------------------------------------------------------------