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.
- checksums.yaml +5 -5
- data/.github/workflows/ci.yml +56 -0
- data/CHANGELOG.md +22 -1
- data/README.md +83 -48
- data/lib/lupa/scope_methods.rb +80 -3
- data/lib/lupa/search.rb +594 -153
- data/lib/lupa/version.rb +1 -1
- data/lib/lupa.rb +128 -4
- data/lupa.gemspec +3 -3
- data/test/test_helper.rb +6 -2
- metadata +18 -21
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
30
|
-
# search.
|
|
31
|
-
# # =>
|
|
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
|
-
#
|
|
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
|
-
#
|
|
183
|
+
# Returns all search attributes including default search attributes.
|
|
184
|
+
# All keys are automatically symbolized and blank values are removed.
|
|
37
185
|
#
|
|
38
|
-
#
|
|
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(
|
|
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(
|
|
61
|
-
# search.search_attributes
|
|
62
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
232
|
+
# Creates a new search instance with the given scope.
|
|
68
233
|
#
|
|
69
|
-
#
|
|
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
|
-
#
|
|
237
|
+
# @param scope [Object] the object to perform search operations on
|
|
72
238
|
#
|
|
73
|
-
#
|
|
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(
|
|
245
|
+
# scope.where(category_id: search_attributes[:category])
|
|
81
246
|
# end
|
|
247
|
+
# end
|
|
248
|
+
# end
|
|
82
249
|
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
# end
|
|
250
|
+
# products = Product.where(price: 20..30)
|
|
251
|
+
# search = ProductSearch.new(products)
|
|
86
252
|
#
|
|
87
|
-
#
|
|
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
|
-
#
|
|
90
|
-
#
|
|
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
|
-
#
|
|
96
|
-
# search = ProductSearch.
|
|
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
|
-
#
|
|
280
|
+
# Returns default search attributes that should always be applied.
|
|
104
281
|
#
|
|
105
|
-
#
|
|
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
|
-
#
|
|
286
|
+
# @return [Hash] a hash of default search attributes (empty hash by default)
|
|
108
287
|
#
|
|
109
|
-
#
|
|
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(
|
|
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
|
-
#
|
|
128
|
-
# search = ProductSearch.new(
|
|
129
|
-
# search.
|
|
130
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
356
|
+
# Performs the search with the given attributes.
|
|
138
357
|
#
|
|
139
|
-
#
|
|
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
|
-
#
|
|
362
|
+
# @param attributes [Hash] the search parameters to apply
|
|
142
363
|
#
|
|
143
|
-
#
|
|
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
|
-
#
|
|
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(
|
|
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
|
-
#
|
|
163
|
-
#
|
|
164
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
439
|
+
# Class method to create a new search instance and perform a search in one call.
|
|
177
440
|
#
|
|
178
|
-
#
|
|
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
|
-
#
|
|
445
|
+
# @param attributes [Hash] the search parameters to apply
|
|
181
446
|
#
|
|
182
|
-
#
|
|
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
|
-
#
|
|
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(
|
|
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.
|
|
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
|
-
#
|
|
208
|
-
# # =>
|
|
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
|
-
#
|
|
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
|
-
#
|
|
505
|
+
# Returns the search results after applying all search methods.
|
|
218
506
|
#
|
|
219
|
-
#
|
|
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
|
-
#
|
|
512
|
+
# @return [Object] the filtered scope (e.g., ActiveRecord::Relation, Array)
|
|
222
513
|
#
|
|
223
|
-
#
|
|
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(
|
|
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
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
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 =
|
|
238
|
-
# # =>
|
|
553
|
+
# search = NumberSearch.new([1, 2, 3, 4, 5, 6]).search(even: true, greater_than: 2)
|
|
554
|
+
# search.results # => [4, 6]
|
|
239
555
|
#
|
|
240
|
-
#
|
|
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
|
-
#
|
|
574
|
+
# Delegates method calls to the search results.
|
|
246
575
|
#
|
|
247
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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(
|
|
592
|
+
# scope.where(category_id: search_attributes[:category])
|
|
255
593
|
# end
|
|
256
|
-
#
|
|
257
594
|
# end
|
|
258
595
|
#
|
|
259
|
-
# def initialize(scope = Product.
|
|
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
|
|
266
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
639
|
+
# Stores the instantiated Scope class that contains all search methods.
|
|
640
|
+
#
|
|
641
|
+
# @return [Lupa::Search::Scope] the scope class instance
|
|
279
642
|
#
|
|
280
|
-
#
|
|
643
|
+
# @api private
|
|
281
644
|
attr_accessor :scope_class
|
|
282
645
|
|
|
283
|
-
#
|
|
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
|
-
#
|
|
649
|
+
# @param attributes [Hash] the raw search attributes
|
|
304
650
|
#
|
|
305
|
-
#
|
|
306
|
-
# { in_stock: true }
|
|
307
|
-
# end
|
|
651
|
+
# @return [Hash] the processed search attributes
|
|
308
652
|
#
|
|
309
|
-
#
|
|
310
|
-
# search = ProductSearch.new(scope).search(category: 'furniture')
|
|
653
|
+
# @api private
|
|
311
654
|
#
|
|
312
|
-
#
|
|
313
|
-
# #
|
|
314
|
-
#
|
|
315
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
370
|
-
#
|
|
371
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
384
|
-
# exception will be raised.
|
|
808
|
+
# @return [Object] the final filtered scope
|
|
385
809
|
#
|
|
386
|
-
#
|
|
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
|
+
|