lupa 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/lupa/search.rb CHANGED
@@ -1,169 +1,432 @@
1
1
  module Lupa
2
+ # Base class for creating search filters using object-oriented design patterns.
3
+ #
4
+ # Lupa::Search provides a structured way to build complex search functionality
5
+ # by defining search methods in a nested Scope class. Each search attribute
6
+ # maps to a method in the Scope class, allowing for clean, testable, and
7
+ # maintainable search logic.
8
+ #
9
+ # = Basic Structure
10
+ #
11
+ # To create a search class:
12
+ # 1. Inherit from `Lupa::Search`
13
+ # 2. Define a nested `Scope` class
14
+ # 3. Implement search methods in the Scope class
15
+ # 4. Optionally define `default_search_attributes`
16
+ #
17
+ # = Features
18
+ #
19
+ # - Framework and ORM agnostic
20
+ # - Works with any object that supports method chaining (ActiveRecord, Array, etc.)
21
+ # - Automatic attribute symbolization and blank value filtering
22
+ # - Support for nested hash attributes
23
+ # - Default search attributes and default scope
24
+ # - Search class composition for reusability
25
+ # - Method delegation to search results
26
+ #
27
+ # @example Basic search class
28
+ # class ProductSearch < Lupa::Search
29
+ # class Scope
30
+ # def name
31
+ # scope.where('name LIKE ?', "%#{search_attributes[:name]}%")
32
+ # end
33
+ #
34
+ # def category
35
+ # scope.where(category_id: search_attributes[:category])
36
+ # end
37
+ #
38
+ # def in_stock
39
+ # scope.where(in_stock: search_attributes[:in_stock])
40
+ # end
41
+ # end
42
+ # end
43
+ #
44
+ # # Usage
45
+ # search = ProductSearch.new(Product.all).search(name: 'chair', category: '23')
46
+ # search.results # => ActiveRecord::Relation
47
+ # search.first # => Product instance (delegates to results)
48
+ # search.count # => 5 (delegates to results)
49
+ #
50
+ # @example With default scope
51
+ # class ProductSearch < Lupa::Search
52
+ # class Scope
53
+ # def name
54
+ # scope.where('name LIKE ?', "%#{search_attributes[:name]}%")
55
+ # end
56
+ # end
57
+ #
58
+ # def initialize(scope = Product.where(active: true))
59
+ # @scope = scope
60
+ # end
61
+ # end
62
+ #
63
+ # # Can use class method without passing scope
64
+ # search = ProductSearch.search(name: 'chair')
65
+ #
66
+ # @example With default search attributes
67
+ # class ProductSearch < Lupa::Search
68
+ # class Scope
69
+ # def category
70
+ # scope.where(category_id: search_attributes[:category])
71
+ # end
72
+ #
73
+ # def in_stock
74
+ # scope.where(in_stock: search_attributes[:in_stock])
75
+ # end
76
+ # end
77
+ #
78
+ # def default_search_attributes
79
+ # { in_stock: true }
80
+ # end
81
+ # end
82
+ #
83
+ # # in_stock will always be applied unless overridden
84
+ # search = ProductSearch.new(Product.all).search(category: '23')
85
+ # search.search_attributes # => { category: '23', in_stock: true }
86
+ #
87
+ # @example Composing search classes
88
+ # class DateRangeSearch < Lupa::Search
89
+ # class Scope
90
+ # def created_between
91
+ # return scope unless start_date && end_date
92
+ # scope.where(created_at: start_date..end_date)
93
+ # end
94
+ #
95
+ # private
96
+ # def start_date
97
+ # search_attributes[:created_between][:start_date]&.to_date
98
+ # end
99
+ #
100
+ # def end_date
101
+ # search_attributes[:created_between][:end_date]&.to_date
102
+ # end
103
+ # end
104
+ # end
105
+ #
106
+ # class ProductSearch < Lupa::Search
107
+ # class Scope
108
+ # def name
109
+ # scope.where('name LIKE ?', "%#{search_attributes[:name]}%")
110
+ # end
111
+ #
112
+ # def created_between
113
+ # DateRangeSearch.new(scope)
114
+ # .search(created_between: search_attributes[:created_between])
115
+ # .results
116
+ # end
117
+ # end
118
+ # end
119
+ #
120
+ # @example Searching arrays
121
+ # numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
122
+ #
123
+ # class NumberSearch < Lupa::Search
124
+ # class Scope
125
+ # def even
126
+ # return scope unless search_attributes[:even]
127
+ # scope.select(&:even?)
128
+ # end
129
+ #
130
+ # def greater_than
131
+ # return scope unless search_attributes[:greater_than]
132
+ # scope.select { |n| n > search_attributes[:greater_than] }
133
+ # end
134
+ # end
135
+ # end
136
+ #
137
+ # search = NumberSearch.new(numbers).search(even: true, greater_than: 5)
138
+ # search.results # => [6, 8, 10]
139
+ #
140
+ # @author Ezequiel Delpero
141
+ # @since 0.1.0
2
142
  class Search
143
+ # Base class for defining search scope methods.
144
+ # All search classes must define a nested Scope class that inherits from this.
145
+ #
146
+ # @example
147
+ # class ProductSearch < Lupa::Search
148
+ # class Scope
149
+ # def name
150
+ # scope.where(name: search_attributes[:name])
151
+ # end
152
+ # end
153
+ # end
3
154
  class Scope; end
4
155
 
5
- # Public: Return class scope.
156
+ # Returns the original scope object passed to the search class.
157
+ # This is the base scope that all search methods will operate on.
6
158
  #
7
- # === Examples
159
+ # @return [Object] the original scope object (e.g., ActiveRecord::Relation, Array)
8
160
  #
161
+ # @example Getting the scope
9
162
  # class ProductSearch < Lupa::Search
10
- #
11
163
  # class Scope
12
- #
13
164
  # def category
14
165
  # scope.where(category: search_attributes[:category])
15
166
  # 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
167
  # end
26
- #
27
168
  # end
28
169
  #
29
- # search = ProductSearch.new(@products).search({ category: 'furniture' })
30
- # search.scope
31
- # # => @products
170
+ # products = Product.where(active: true)
171
+ # search = ProductSearch.new(products).search(category: 'furniture')
172
+ # search.scope # => #<Product::ActiveRecord_Relation [...]> (original products scope)
32
173
  #
33
- # Returns your class scope.
174
+ # @example With arrays
175
+ # numbers = [1, 2, 3, 4, 5]
176
+ # search = NumberSearch.new(numbers).search(even: true)
177
+ # search.scope # => [1, 2, 3, 4, 5] (original array)
178
+ #
179
+ # @note This returns the original scope, not the filtered results.
180
+ # Use `results` to get the filtered scope after search methods are applied.
34
181
  attr_reader :scope
35
182
 
36
- # Public: Return class search attributes including default search attributes.
183
+ # Returns all search attributes including default search attributes.
184
+ # All keys are automatically symbolized and blank values are removed.
37
185
  #
38
- # === Examples
186
+ # @return [Hash] the search attributes hash with symbolized keys
39
187
  #
188
+ # @example Basic usage
40
189
  # class ProductSearch < Lupa::Search
41
- #
42
190
  # class Scope
191
+ # def name
192
+ # scope.where(name: search_attributes[:name])
193
+ # end
194
+ # end
195
+ # end
43
196
  #
197
+ # search = ProductSearch.new(Product.all).search('name' => 'chair')
198
+ # search.search_attributes # => { name: 'chair' }
199
+ #
200
+ # @example With default search attributes
201
+ # class ProductSearch < Lupa::Search
202
+ # class Scope
44
203
  # def category
45
- # scope.where(category: search_attributes[:category])
204
+ # scope.where(category_id: search_attributes[:category])
46
205
  # end
47
206
  #
48
207
  # def in_stock
49
208
  # scope.where(in_stock: search_attributes[:in_stock])
50
209
  # end
51
- #
52
210
  # end
53
211
  #
54
212
  # def default_search_attributes
55
213
  # { in_stock: true }
56
214
  # end
57
- #
58
215
  # end
59
216
  #
60
- # search = ProductSearch.new(@products).search({ category: 'furniture' })
61
- # search.search_attributes
62
- # # => { category: furniture, in_stock: true }
217
+ # search = ProductSearch.new(Product.all).search(category: 'furniture')
218
+ # search.search_attributes # => { category: 'furniture', in_stock: true }
219
+ #
220
+ # @example Blank values are removed
221
+ # search = ProductSearch.new(Product.all).search(name: 'chair', category: '')
222
+ # search.search_attributes # => { name: 'chair' }
63
223
  #
64
- # Returns your class search attributes including default search attributes.
224
+ # @example Nested hash attributes
225
+ # search = ProductSearch.new(Product.all).search(
226
+ # created_between: { start_date: '2023-01-01', end_date: '2023-12-31' }
227
+ # )
228
+ # search.search_attributes
229
+ # # => { created_between: { start_date: '2023-01-01', end_date: '2023-12-31' } }
65
230
  attr_reader :search_attributes
66
231
 
67
- # Public: Create a new instance of the class.
232
+ # Creates a new search instance with the given scope.
68
233
  #
69
- # === Options
234
+ # The scope can be any object that supports method chaining, such as an
235
+ # ActiveRecord::Relation, Mongoid::Criteria, or even a plain Ruby Array.
70
236
  #
71
- # <tt>scope</tt> - An object which will be use to perform all the search operations.
237
+ # @param scope [Object] the object to perform search operations on
72
238
  #
73
- # === Examples
239
+ # @return [Lupa::Search] a new search instance
74
240
  #
241
+ # @example With ActiveRecord
75
242
  # class ProductSearch < Lupa::Search
76
- #
77
243
  # class Scope
78
- #
79
244
  # def category
80
- # scope.where(category: search_attributes[:category])
245
+ # scope.where(category_id: search_attributes[:category])
81
246
  # end
247
+ # end
248
+ # end
82
249
  #
83
- # def in_stock
84
- # scope.where(in_stock: search_attributes[:in_stock])
85
- # end
250
+ # products = Product.where(price: 20..30)
251
+ # search = ProductSearch.new(products)
86
252
  #
87
- # end
253
+ # @example With a scoped relation
254
+ # active_products = Product.where(active: true).includes(:category)
255
+ # search = ProductSearch.new(active_products).search(name: 'chair')
88
256
  #
89
- # def default_search_attributes
90
- # { in_stock: true }
257
+ # @example With arrays
258
+ # numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
259
+ # search = NumberSearch.new(numbers).search(even: true)
260
+ #
261
+ # @example Defining a default scope
262
+ # class ProductSearch < Lupa::Search
263
+ # class Scope
264
+ # def category
265
+ # scope.where(category_id: search_attributes[:category])
266
+ # end
91
267
  # end
92
268
  #
269
+ # def initialize(scope = Product.where(active: true))
270
+ # @scope = scope
271
+ # end
93
272
  # end
94
273
  #
95
- # scope = Product.where(price: 20..30)
96
- # search = ProductSearch.new(scope)
97
- #
98
- # Returns a new instance of the class.
274
+ # # Can now use without passing a scope
275
+ # search = ProductSearch.search(category: '23')
99
276
  def initialize(scope)
100
277
  @scope = scope
101
278
  end
102
279
 
103
- # Public: Return default a hash containing default search attributes of the class.
280
+ # Returns default search attributes that should always be applied.
104
281
  #
105
- # === Examples
282
+ # Override this method in your search class to define attributes that should
283
+ # always be included in the search, regardless of what's passed to the `search` method.
284
+ # Default attributes can be overridden by explicitly passing them in search params.
106
285
  #
107
- # class ProductSearch < Lupa::Search
286
+ # @return [Hash] a hash of default search attributes (empty hash by default)
108
287
  #
109
- # class Scope
288
+ # @raise [Lupa::DefaultSearchAttributesError] if the return value is not a Hash
110
289
  #
290
+ # @example Defining default search attributes
291
+ # class ProductSearch < Lupa::Search
292
+ # class Scope
111
293
  # def category
112
- # scope.where(category: search_attributes[:category])
294
+ # scope.where(category_id: search_attributes[:category])
113
295
  # end
114
296
  #
115
297
  # def in_stock
116
298
  # scope.where(in_stock: search_attributes[:in_stock])
117
299
  # end
118
- #
119
300
  # end
120
301
  #
121
302
  # def default_search_attributes
122
303
  # { in_stock: true }
123
304
  # end
305
+ # end
124
306
  #
307
+ # search = ProductSearch.new(Product.all).search(category: 'furniture')
308
+ # search.default_search_attributes # => { in_stock: true }
309
+ # search.search_attributes # => { category: 'furniture', in_stock: true }
310
+ #
311
+ # @example Overriding default attributes
312
+ # class ProductSearch < Lupa::Search
313
+ # class Scope
314
+ # def status
315
+ # scope.where(status: search_attributes[:status])
316
+ # end
317
+ # end
318
+ #
319
+ # def default_search_attributes
320
+ # { status: 'active' }
321
+ # end
125
322
  # end
126
323
  #
127
- # scope = Product.where(price: 20..30)
128
- # search = ProductSearch.new(scope).search({ category: 'furniture' })
129
- # search.default_search_attributes
130
- # # => { in_stock: true }
324
+ # # Using default
325
+ # search = ProductSearch.new(Product.all).search({})
326
+ # search.search_attributes # => { status: 'active' }
327
+ #
328
+ # # Overriding default
329
+ # search = ProductSearch.new(Product.all).search(status: 'archived')
330
+ # search.search_attributes # => { status: 'archived' }
331
+ #
332
+ # @example Using with conditional defaults
333
+ # class ProductSearch < Lupa::Search
334
+ # class Scope
335
+ # def visibility
336
+ # scope.where(visibility: search_attributes[:visibility])
337
+ # end
338
+ # end
339
+ #
340
+ # def initialize(scope = Product.all, current_user: nil)
341
+ # @scope = scope
342
+ # @current_user = current_user
343
+ # end
344
+ #
345
+ # def default_search_attributes
346
+ # return { visibility: 'public' } unless @current_user&.admin?
347
+ # {}
348
+ # end
349
+ # end
131
350
  #
132
- # Returns default a hash containing default search attributes of the class.
351
+ # @note This method must return a Hash or a Lupa::DefaultSearchAttributesError will be raised
133
352
  def default_search_attributes
134
353
  {}
135
354
  end
136
355
 
137
- # Public: Set and checks search attributes, and instantiates the Scope class.
356
+ # Performs the search with the given attributes.
138
357
  #
139
- # === Options
358
+ # This method processes the search attributes (symbolizing keys, merging with
359
+ # defaults, removing blank values), validates that all attribute keys have
360
+ # corresponding methods in the Scope class, and returns self for method chaining.
140
361
  #
141
- # <tt>attributes</tt> - The hash containing the search attributes.
362
+ # @param attributes [Hash] the search parameters to apply
142
363
  #
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.
364
+ # @return [self] returns the search instance for method chaining
147
365
  #
148
- # === Examples
366
+ # @raise [Lupa::SearchAttributesError] if attributes doesn't respond to `keys` method
367
+ # @raise [Lupa::ScopeMethodNotImplementedError] if an attribute key doesn't have
368
+ # a corresponding method in the Scope class
369
+ # @raise [Lupa::DefaultSearchAttributesError] if `default_search_attributes` doesn't return a Hash
149
370
  #
371
+ # @example Basic usage
150
372
  # class ProductSearch < Lupa::Search
151
- #
152
373
  # class Scope
374
+ # def name
375
+ # scope.where('name LIKE ?', "%#{search_attributes[:name]}%")
376
+ # end
153
377
  #
154
378
  # def category
155
- # scope.where(category: search_attributes[:category])
379
+ # scope.where(category_id: search_attributes[:category])
156
380
  # end
157
- #
158
381
  # end
382
+ # end
383
+ #
384
+ # search = ProductSearch.new(Product.all).search(name: 'chair', category: '23')
385
+ # # Returns the search instance for further operations
386
+ #
387
+ # @example Method chaining
388
+ # products = ProductSearch.new(Product.all)
389
+ # .search(name: 'chair', category: '23')
390
+ # .results
391
+ #
392
+ # @example Accessing results through delegation
393
+ # search = ProductSearch.new(Product.all).search(name: 'chair')
394
+ # search.first # Delegates to results.first
395
+ # search.count # Delegates to results.count
396
+ # search.each { |p| puts p.name } # Delegates to results.each
159
397
  #
398
+ # @example String keys are symbolized
399
+ # search = ProductSearch.new(Product.all).search('name' => 'chair', 'category' => '23')
400
+ # search.search_attributes # => { name: 'chair', category: '23' }
401
+ #
402
+ # @example Blank values are removed
403
+ # search = ProductSearch.new(Product.all).search(name: 'chair', category: '', price: nil)
404
+ # search.search_attributes # => { name: 'chair' }
405
+ #
406
+ # @example Nested hash attributes
407
+ # class ProductSearch < Lupa::Search
408
+ # class Scope
409
+ # def created_between
410
+ # start_date = search_attributes[:created_between][:start_date]
411
+ # end_date = search_attributes[:created_between][:end_date]
412
+ # scope.where(created_at: start_date..end_date)
413
+ # end
414
+ # end
160
415
  # end
161
416
  #
162
- # scope = Product.where(price: 20..30)
163
- # search = ProductSearch.new(scope).search({ category: 'furniture' })
164
- # # => #<ProductSearch:0x007f7f74070850 @scope=scope, @search_attributes={:category=>'furniture'}, @scope_class=#<ProductSearch::Scope:0x007fd2811001e8 @scope=[1, 2, 3, 4, 5, 6, 7, 8], @search_attributes={:even_numbers=>true}>>
417
+ # search = ProductSearch.new(Product.all).search(
418
+ # created_between: { start_date: '2023-01-01', end_date: '2023-12-31' }
419
+ # )
420
+ #
421
+ # @example Error handling - invalid attributes type
422
+ # ProductSearch.new(Product.all).search("not a hash")
423
+ # # => Lupa::SearchAttributesError: Your search params needs to be a hash.
424
+ #
425
+ # @example Error handling - undefined scope method
426
+ # ProductSearch.new(Product.all).search(undefined_attribute: 'value')
427
+ # # => Lupa::ScopeMethodNotImplementedError: undefined_attribute is not defined on your ProductSearch::Scope class.
165
428
  #
166
- # Returns the class instance itself.
429
+ # @note Search methods are not executed until you call `results` or a delegated method
167
430
  def search(attributes)
168
431
  raise Lupa::SearchAttributesError, "Your search params needs to be a hash." unless attributes.respond_to?(:keys)
169
432
 
@@ -173,99 +436,197 @@ module Lupa
173
436
  self
174
437
  end
175
438
 
176
- # Public: Creates a new instance of the search class an applies search method with attributes to it.
439
+ # Class method to create a new search instance and perform a search in one call.
177
440
  #
178
- # === Options
441
+ # This is a convenience method that creates a new instance without a scope parameter
442
+ # and then calls the instance `search` method. This only works if you've defined
443
+ # a default scope in your `initialize` method.
179
444
  #
180
- # <tt>attributes</tt> - The hash containing the search attributes.
445
+ # @param attributes [Hash] the search parameters to apply
181
446
  #
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.
447
+ # @return [Lupa::Search] the search instance with applied search attributes
188
448
  #
189
- # === Examples
449
+ # @raise [Lupa::DefaultScopeError] if no default scope is defined in `initialize`
450
+ # @raise [Lupa::SearchAttributesError] if attributes doesn't respond to `keys` method
451
+ # @raise [Lupa::ScopeMethodNotImplementedError] if an attribute key doesn't have
452
+ # a corresponding method in the Scope class
190
453
  #
454
+ # @example With default scope defined
191
455
  # class ProductSearch < Lupa::Search
192
- #
193
456
  # class Scope
194
- #
195
457
  # def category
196
- # scope.where(category: search_attributes[:category])
458
+ # scope.where(category_id: search_attributes[:category])
197
459
  # end
198
460
  #
461
+ # def name
462
+ # scope.where('name LIKE ?', "%#{search_attributes[:name]}%")
463
+ # end
199
464
  # end
200
465
  #
201
- # def initialize(scope = Product.in_stock)
466
+ # def initialize(scope = Product.where(active: true))
202
467
  # @scope = scope
203
468
  # end
469
+ # end
470
+ #
471
+ # # Can use the class method
472
+ # search = ProductSearch.search(category: 'furniture', name: 'chair')
473
+ # search.results # => filtered products
204
474
  #
475
+ # @example Without default scope (will raise error)
476
+ # class ProductSearch < Lupa::Search
477
+ # class Scope
478
+ # def category
479
+ # scope.where(category_id: search_attributes[:category])
480
+ # end
481
+ # end
482
+ # # No default scope in initialize
205
483
  # end
206
484
  #
207
- # search = ProductSearch.search({ category: 'furniture' })
208
- # # => #<ProductSearch:0x007f7f74070850 @scope=scope, @search_attributes={:category=>'furniture'}, @scope_class=#<ProductSearch::Scope:0x007fd2811001e8 @scope=[1, 2, 3, 4, 5, 6, 7, 8], @search_attributes={:even_numbers=>true}>>
485
+ # ProductSearch.search(category: 'furniture')
486
+ # # => Lupa::DefaultScopeError: You need to define a default scope in order to user search class method.
487
+ #
488
+ # @example Chaining with results
489
+ # products = ProductSearch.search(category: 'furniture').results
490
+ # products.each { |p| puts p.name }
209
491
  #
210
- # Returns the class instance itself.
492
+ # @example Using delegation
493
+ # # These all work because of method delegation to results
494
+ # ProductSearch.search(category: 'furniture').first
495
+ # ProductSearch.search(category: 'furniture').count
496
+ # ProductSearch.search(category: 'furniture').each { |p| puts p.name }
497
+ #
498
+ # @note If you need to pass a custom scope, use `ProductSearch.new(scope).search(attributes)` instead
211
499
  def self.search(attributes)
212
500
  new.search(attributes)
213
501
  rescue ArgumentError
214
502
  raise Lupa::DefaultScopeError, "You need to define a default scope in order to user search class method."
215
503
  end
216
504
 
217
- # Public: Return the search result.
505
+ # Returns the search results after applying all search methods.
218
506
  #
219
- # === Examples
507
+ # This method executes the search by calling each method defined in the Scope class
508
+ # that corresponds to a key in search_attributes. The methods are called in the
509
+ # order they appear in the search_attributes hash. Results are memoized, so
510
+ # calling this method multiple times won't re-execute the search.
220
511
  #
221
- # class ProductSearch < Lupa::Search
512
+ # @return [Object] the filtered scope (e.g., ActiveRecord::Relation, Array)
222
513
  #
223
- # class Scope
514
+ # @raise [Lupa::SearchAttributesError] if search attributes weren't set
224
515
  #
516
+ # @example Basic usage
517
+ # class ProductSearch < Lupa::Search
518
+ # class Scope
225
519
  # def category
226
- # scope.where(category: search_attributes[:category])
520
+ # scope.where(category_id: search_attributes[:category])
227
521
  # end
228
522
  #
523
+ # def name
524
+ # scope.where('name LIKE ?', "%#{search_attributes[:name]}%")
525
+ # end
229
526
  # end
527
+ # end
230
528
  #
231
- # def initialize(scope = Product.in_stock)
232
- # @scope = scope
233
- # end
529
+ # search = ProductSearch.new(Product.all).search(category: 'furniture', name: 'chair')
530
+ # search.results # => #<Product::ActiveRecord_Relation:0x007ffda11b7d48>
531
+ #
532
+ # @example Results are memoized
533
+ # search = ProductSearch.new(Product.all).search(category: 'furniture')
534
+ # results1 = search.results # Executes the search
535
+ # results2 = search.results # Returns cached results (same object)
536
+ # results1.object_id == results2.object_id # => true
537
+ #
538
+ # @example With arrays
539
+ # class NumberSearch < Lupa::Search
540
+ # class Scope
541
+ # def even
542
+ # return scope unless search_attributes[:even]
543
+ # scope.select(&:even?)
544
+ # end
234
545
  #
546
+ # def greater_than
547
+ # return scope unless search_attributes[:greater_than]
548
+ # scope.select { |n| n > search_attributes[:greater_than] }
549
+ # end
550
+ # end
235
551
  # end
236
552
  #
237
- # search = ProductSearch.search({ category: 'furniture' }).results
238
- # # => #<Product::ActiveRecord_Relation:0x007ffda11b7d48>
553
+ # search = NumberSearch.new([1, 2, 3, 4, 5, 6]).search(even: true, greater_than: 2)
554
+ # search.results # => [4, 6]
239
555
  #
240
- # Returns the search result.
556
+ # @example Scope methods returning nil are handled gracefully
557
+ # class ProductSearch < Lupa::Search
558
+ # class Scope
559
+ # def optional_filter
560
+ # # If condition not met, return nil - scope won't be updated
561
+ # return nil unless search_attributes[:optional_filter]
562
+ # scope.where(some_field: search_attributes[:optional_filter])
563
+ # end
564
+ # end
565
+ # end
566
+ #
567
+ # @note Methods in your Scope class should return either the modified scope
568
+ # or nil (if no filtering should be applied). Returning nil prevents the
569
+ # scope from being updated for that particular search method.
241
570
  def results
242
571
  @results ||= run
243
572
  end
244
573
 
245
- # Public: Apply the missing method to the search result.
574
+ # Delegates method calls to the search results.
246
575
  #
247
- # === Examples
576
+ # This allows you to call any method that the results object responds to
577
+ # directly on the search instance, making the search object behave like
578
+ # the results collection.
248
579
  #
249
- # class ProductSearch < Lupa::Search
580
+ # @param method_sym [Symbol] the method name to delegate
581
+ # @param arguments [Array] arguments to pass to the delegated method
582
+ # @param block [Proc] block to pass to the delegated method
250
583
  #
251
- # class Scope
584
+ # @return [Object] the return value of the delegated method
585
+ #
586
+ # @raise [Lupa::ResultMethodNotImplementedError] if the results don't respond to the method
252
587
  #
588
+ # @example Delegating Array/Relation methods
589
+ # class ProductSearch < Lupa::Search
590
+ # class Scope
253
591
  # def category
254
- # scope.where(category: search_attributes[:category])
592
+ # scope.where(category_id: search_attributes[:category])
255
593
  # end
256
- #
257
594
  # end
258
595
  #
259
- # def initialize(scope = Product.in_stock)
596
+ # def initialize(scope = Product.all)
260
597
  # @scope = scope
261
598
  # end
599
+ # end
600
+ #
601
+ # search = ProductSearch.search(category: 'furniture')
262
602
  #
603
+ # # All these methods are delegated to results
604
+ # search.first # => #<Product:0x007f9c0ce1b1a8>
605
+ # search.count # => 42
606
+ # search.empty? # => false
607
+ # search.pluck(:name) # => ["Chair", "Table", ...]
608
+ #
609
+ # @example Iterating with blocks
610
+ # search = ProductSearch.search(category: 'furniture')
611
+ #
612
+ # search.each do |product|
613
+ # puts product.name
263
614
  # end
264
615
  #
265
- # search = ProductSearch.search({ category: 'furniture' }).first
266
- # # => #<Product:0x007f9c0ce1b1a8>
616
+ # search.map(&:name) # => ["Chair", "Table", ...]
617
+ #
618
+ # @example Error when method doesn't exist
619
+ # search = ProductSearch.search(category: 'furniture')
620
+ # search.non_existent_method
621
+ # # => Lupa::ResultMethodNotImplementedError: The resulting scope does not respond to non_existent_method method.
267
622
  #
268
- # Returns the search result.
623
+ # @example Chaining delegated methods
624
+ # search = ProductSearch.search(category: 'furniture')
625
+ # search.limit(10).offset(20).to_a
626
+ # # All these methods are delegated to the results
627
+ #
628
+ # @note This is what allows search objects to be used directly in views
629
+ # without explicitly calling `.results` first
269
630
  def method_missing(method_sym, *arguments, &block)
270
631
  if results.respond_to?(method_sym)
271
632
  results.send(method_sym, *arguments, &block)
@@ -275,44 +636,29 @@ module Lupa
275
636
  end
276
637
 
277
638
  private
278
- # Internal: Store the scope class.
639
+ # Stores the instantiated Scope class that contains all search methods.
640
+ #
641
+ # @return [Lupa::Search::Scope] the scope class instance
279
642
  #
280
- # Stores the scope class.
643
+ # @api private
281
644
  attr_accessor :scope_class
282
645
 
283
- # Internal: Set @search_attributes by merging default search attributes with the ones passed to search method.
284
- #
285
- # === Options
286
- #
287
- # <tt>attributes</tt> - 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
646
+ # Processes and sets search attributes by merging with defaults, symbolizing keys,
647
+ # and removing blank values.
302
648
  #
303
- # end
649
+ # @param attributes [Hash] the raw search attributes
304
650
  #
305
- # def default_search_attributes
306
- # { in_stock: true }
307
- # end
651
+ # @return [Hash] the processed search attributes
308
652
  #
309
- # scope = Product.where(in_warehouse: true)
310
- # search = ProductSearch.new(scope).search(category: 'furniture')
653
+ # @api private
311
654
  #
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.
655
+ # @example Internal usage
656
+ # # Given a search class with default_search_attributes { in_stock: true }
657
+ # set_search_attributes('category' => 'furniture', 'name' => '')
658
+ # # Results in: { category: 'furniture', in_stock: true }
659
+ # # - Merged with defaults
660
+ # # - Keys symbolized
661
+ # # - Blank 'name' removed
316
662
  def set_search_attributes(attributes)
317
663
  attributes = merge_search_attributes(attributes)
318
664
  attributes = symbolize_keys(attributes)
@@ -321,14 +667,39 @@ module Lupa
321
667
  @search_attributes = attributes
322
668
  end
323
669
 
324
- # Internal: Merge search attributes with default search attributes
670
+ # Merges the provided attributes with default search attributes.
671
+ # Default attributes can be overridden by explicitly providing them.
672
+ #
673
+ # @param attributes [Hash] the attributes to merge with defaults
674
+ #
675
+ # @return [Hash] the merged attributes hash
676
+ #
677
+ # @raise [Lupa::DefaultSearchAttributesError] if default_search_attributes
678
+ # doesn't return a Hash
679
+ #
680
+ # @api private
325
681
  def merge_search_attributes(attributes)
326
682
  return default_search_attributes.merge(attributes) if default_search_attributes.kind_of?(Hash)
327
683
 
328
684
  raise Lupa::DefaultSearchAttributesError, "default_search_attributes doesn't return a Hash."
329
685
  end
330
686
 
331
- # Internal: Symbolizes all keys passed to the search attributes.
687
+ # Recursively symbolizes all hash keys in the attributes.
688
+ # Works with nested hashes and arrays.
689
+ #
690
+ # @param attributes [Hash, Array, Object] the attributes to symbolize
691
+ #
692
+ # @return [Hash, Array, Object] attributes with symbolized keys
693
+ #
694
+ # @api private
695
+ #
696
+ # @example With nested hash
697
+ # symbolize_keys('name' => 'chair', 'range' => { 'min' => 10, 'max' => 20 })
698
+ # # => { name: 'chair', range: { min: 10, max: 20 } }
699
+ #
700
+ # @example With array
701
+ # symbolize_keys([{ 'id' => 1 }, { 'id' => 2 }])
702
+ # # => [{ id: 1 }, { id: 2 }]
332
703
  def symbolize_keys(attributes)
333
704
  return attributes.reduce({}) do |attribute, (key, value)|
334
705
  attribute.tap { |a| a[key.to_sym] = symbolize_keys(value) }
@@ -341,12 +712,44 @@ module Lupa
341
712
  attributes
342
713
  end
343
714
 
344
- # Internal: Removes all empty values passed to search attributes.
715
+ # Removes all blank values from the attributes hash.
716
+ # A value is considered blank if it's an empty string, nil, or a hash/array
717
+ # that contains only blank values.
718
+ #
719
+ # @param attributes [Hash] the attributes to clean
720
+ #
721
+ # @return [Hash] attributes with blank values removed
722
+ #
723
+ # @api private
724
+ #
725
+ # @example
726
+ # remove_blank_attributes(name: 'chair', category: '', price: nil, tags: [])
727
+ # # => { name: 'chair' }
345
728
  def remove_blank_attributes(attributes)
346
729
  attributes.delete_if { |key, value| clean_attribute(value) }
347
730
  end
348
731
 
349
- # Internal: Iterates over value child attributes to remove empty values.
732
+ # Recursively determines if a value is blank (empty or contains only blank values).
733
+ # Modifies hashes and arrays in place by removing blank values.
734
+ #
735
+ # @param value [Object] the value to check
736
+ #
737
+ # @return [Boolean] true if the value is blank, false otherwise
738
+ #
739
+ # @api private
740
+ #
741
+ # @example With hash
742
+ # clean_attribute({ min: '', max: '' }) # => true
743
+ # clean_attribute({ min: 10, max: '' }) # => false
744
+ #
745
+ # @example With array
746
+ # clean_attribute(['', nil, ' ']) # => true
747
+ # clean_attribute(['valid', '']) # => false
748
+ #
749
+ # @example With strings
750
+ # clean_attribute('') # => true
751
+ # clean_attribute(' ') # => true
752
+ # clean_attribute('value') # => false
350
753
  def clean_attribute(value)
351
754
  if value.kind_of?(Hash)
352
755
  value.delete_if { |key, value| clean_attribute(value) }.empty?
@@ -357,18 +760,36 @@ module Lupa
357
760
  end
358
761
  end
359
762
 
360
- # Internal: Includes ScopeMethods module into the Scope class and instantiate it.
763
+ # Includes the ScopeMethods module into the Scope class and instantiates it.
764
+ #
765
+ # This method dynamically includes the ScopeMethods module which provides
766
+ # access to `scope` and `search_attributes` within scope methods.
767
+ #
768
+ # @return [Lupa::Search::Scope] the instantiated scope class
769
+ #
770
+ # @api private
361
771
  def set_scope_class
362
772
  klass = self.class::Scope
363
773
  klass.send(:include, ScopeMethods)
364
774
  @scope_class = klass.new(@scope, @search_attributes)
365
775
  end
366
776
 
367
- # Internal: Check for search methods to be correctly defined using search attributes.
777
+ # Validates that all search attribute keys have corresponding methods in the Scope class.
778
+ #
779
+ # @return [void]
780
+ #
781
+ # @raise [Lupa::ScopeMethodNotImplementedError] if a search attribute doesn't
782
+ # have a corresponding method in the Scope class
368
783
  #
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.
784
+ # @api private
785
+ #
786
+ # @example Valid methods
787
+ # # Given search_attributes: { name: 'chair', category: '23' }
788
+ # # The Scope class must have both `name` and `category` methods defined
789
+ #
790
+ # @example Error case
791
+ # # Given search_attributes: { undefined_method: 'value' }
792
+ # # Raises: Lupa::ScopeMethodNotImplementedError: undefined_method is not defined on your ProductSearch::Scope class.
372
793
  def check_method_definitions
373
794
  method_names = search_attributes.keys
374
795
 
@@ -378,12 +799,30 @@ module Lupa
378
799
  end
379
800
  end
380
801
 
381
- # Internal: Applies search attributes keys as methods over the scope_class.
802
+ # Executes the search by calling each scope method corresponding to the search attributes.
803
+ #
804
+ # Iterates through each key in search_attributes and calls the corresponding
805
+ # method on the scope_class. If a method returns nil, the scope is not updated
806
+ # for that particular search attribute.
382
807
  #
383
- # * If search_attributes are not specified a Lupa::SearchAttributesError
384
- # exception will be raised.
808
+ # @return [Object] the final filtered scope
385
809
  #
386
- # Returns the result of the search.
810
+ # @raise [Lupa::SearchAttributesError] if search_attributes is not set
811
+ #
812
+ # @api private
813
+ #
814
+ # @example Execution flow
815
+ # # Given search_attributes: { name: 'chair', category: '23' }
816
+ # # Calls: scope_class.name
817
+ # # Then: scope_class.category
818
+ # # Returns: scope_class.scope (the final filtered result)
819
+ #
820
+ # @example Handling nil returns
821
+ # # If a scope method returns nil, the scope doesn't change
822
+ # def optional_filter
823
+ # return nil unless some_condition
824
+ # scope.where(...)
825
+ # end
387
826
  def run
388
827
  raise Lupa::SearchAttributesError, "You need to specify search attributes." unless search_attributes
389
828
 
@@ -397,3 +836,5 @@ module Lupa
397
836
 
398
837
  end
399
838
  end
839
+
840
+