├── .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 | 
2 |
3 | Lupa means *Magnifier* in spanish.
4 |
5 | [](https://travis-ci.org/edelpero/lupa) [](https://coveralls.io/r/edelpero/lupa?branch=master) [](https://codeclimate.com/github/edelpero/lupa) [](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 |
--------------------------------------------------------------------------------