better_model 1.0.0

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.
@@ -0,0 +1,524 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Searchable - Sistema di ricerca unificato per modelli Rails
4
+ #
5
+ # Questo concern orchestra Predicable e Sortable per fornire un'interfaccia
6
+ # di ricerca completa con filtri, ordinamento e paginazione.
7
+ #
8
+ # Esempio di utilizzo:
9
+ # class Article < ApplicationRecord
10
+ # include BetterModel
11
+ #
12
+ # predicates :title, :status, :view_count, :published_at
13
+ # sort :title, :view_count, :published_at
14
+ #
15
+ # searchable do
16
+ # per_page 25
17
+ # max_per_page 100
18
+ # end
19
+ # end
20
+ #
21
+ # Utilizzo:
22
+ # Article.search(
23
+ # { title_cont: "Rails", status_eq: "published" },
24
+ # pagination: { page: 1, per_page: 25 },
25
+ # orders: [:sort_published_at_desc, :sort_title_asc]
26
+ # )
27
+ #
28
+ module BetterModel
29
+ module Searchable
30
+ extend ActiveSupport::Concern
31
+
32
+ # Error classes
33
+ class Error < StandardError; end
34
+ class InvalidPredicateError < Error; end
35
+ class InvalidOrderError < Error; end
36
+ class InvalidPaginationError < Error; end
37
+ class InvalidSecurityError < Error; end
38
+
39
+ included do
40
+ # Valida che sia incluso solo in modelli ActiveRecord
41
+ unless ancestors.include?(ActiveRecord::Base)
42
+ raise ArgumentError, "BetterModel::Searchable can only be included in ActiveRecord models"
43
+ end
44
+
45
+ # Configuration registry
46
+ class_attribute :searchable_config, default: {
47
+ default_order: nil,
48
+ per_page: nil,
49
+ max_per_page: nil,
50
+ securities: {}
51
+ }.freeze
52
+ end
53
+
54
+ class_methods do
55
+ # Interfaccia principale di ricerca
56
+ #
57
+ # @param predicates [Hash] Condizioni di filtro (usa scope Predicable)
58
+ # @option predicates [Array<Hash>] :or Condizioni OR
59
+ # @param pagination [Hash] Parametri di paginazione (opzionale)
60
+ # @option pagination [Integer] :page Numero pagina
61
+ # @option pagination [Integer] :per_page Risultati per pagina
62
+ # @param orders [Array<Symbol>] Array di symbol scope sortable (opzionale)
63
+ #
64
+ # @return [ActiveRecord::Relation] Relation concatenabile
65
+ #
66
+ # @example Ricerca base
67
+ # Article.search({ title_cont: "Rails" })
68
+ #
69
+ # @example Con paginazione e ordinamento
70
+ # Article.search(
71
+ # { title_cont: "Rails", status_eq: "published" },
72
+ # pagination: { page: 1, per_page: 25 },
73
+ # orders: [:sort_published_at_desc]
74
+ # )
75
+ #
76
+ # @example Con OR conditions
77
+ # Article.search(
78
+ # {
79
+ # or: [
80
+ # { title_cont: "Rails" },
81
+ # { title_cont: "Ruby" }
82
+ # ],
83
+ # status_eq: "published"
84
+ # },
85
+ # orders: [:sort_view_count_desc]
86
+ # )
87
+ #
88
+ def search(predicates = {}, **options)
89
+ # Extract and validate keyword arguments
90
+ pagination = options.delete(:pagination)
91
+ orders = options.delete(:orders)
92
+ security = options.delete(:security)
93
+
94
+ # If there are remaining unknown options, they might be misplaced predicates
95
+ if options.any?
96
+ raise ArgumentError, "Unknown keyword arguments: #{options.keys.join(', ')}. " \
97
+ "Did you mean to pass predicates as a hash? Use: search({#{options.keys.first}: ...})"
98
+ end
99
+
100
+ # Sanitize predicates
101
+ predicates = sanitize_predicates(predicates)
102
+
103
+ # Extract OR conditions from predicates hash
104
+ or_conditions = predicates.delete(:or)
105
+
106
+ # Validate query complexity limits to prevent DoS attacks
107
+ validate_query_complexity(predicates, or_conditions)
108
+
109
+ # Validate security if specified
110
+ # Valida sia i predicati AND che quelli dentro le condizioni OR
111
+ if security.present?
112
+ validate_security(security, predicates)
113
+ validate_security_in_or_conditions(security, or_conditions) if or_conditions.present?
114
+ end
115
+
116
+ # Start with base scope
117
+ scope = all
118
+
119
+ # Apply AND predicates
120
+ scope = apply_predicates(scope, predicates) if predicates.any?
121
+
122
+ # Apply OR conditions
123
+ scope = apply_or_conditions(scope, or_conditions) if or_conditions.present?
124
+
125
+ # Apply orders: use provided orders, or fall back to default_order
126
+ if orders.present?
127
+ scope = apply_orders(scope, orders)
128
+ elsif searchable_config[:default_order].present?
129
+ scope = apply_orders(scope, searchable_config[:default_order])
130
+ end
131
+
132
+ # Apply pagination
133
+ scope = apply_pagination(scope, pagination) if pagination.present?
134
+
135
+ scope
136
+ end
137
+
138
+ # DSL per configurare searchable
139
+ #
140
+ # @example
141
+ # searchable do
142
+ # per_page 25
143
+ # max_per_page 100
144
+ # end
145
+ def searchable(&block)
146
+ configurator = SearchableConfigurator.new(self)
147
+ configurator.instance_eval(&block)
148
+ self.searchable_config = configurator.to_h.freeze
149
+ end
150
+
151
+ # Verifica se un campo è predicable
152
+ def searchable_field?(field_name)
153
+ respond_to?(:predicable_field?) && predicable_field?(field_name)
154
+ end
155
+
156
+ # Ritorna tutti i campi predicable
157
+ def searchable_fields
158
+ respond_to?(:predicable_fields) ? predicable_fields : Set.new
159
+ end
160
+
161
+ # Ritorna i predicati disponibili per un campo
162
+ #
163
+ # @param field_name [Symbol] Nome del campo
164
+ # @return [Array<Symbol>] Array di predicati disponibili
165
+ #
166
+ # @example
167
+ # Article.searchable_predicates_for(:title)
168
+ # # => [:eq, :not_eq, :cont, :i_cont, :start, :end, ...]
169
+ def searchable_predicates_for(field_name)
170
+ return [] unless searchable_field?(field_name)
171
+ return [] unless respond_to?(:predicable_scopes)
172
+
173
+ predicable_scopes
174
+ .select { |scope| scope.to_s.start_with?("#{field_name}_") }
175
+ .map { |scope| scope.to_s.delete_prefix("#{field_name}_").to_sym }
176
+ end
177
+
178
+ # Ritorna gli ordinamenti disponibili per un campo
179
+ #
180
+ # @param field_name [Symbol] Nome del campo
181
+ # @return [Array<Symbol>] Array di scope sortable disponibili
182
+ #
183
+ # @example
184
+ # Article.searchable_sorts_for(:title)
185
+ # # => [:sort_title_asc, :sort_title_desc, :sort_title_asc_i, :sort_title_desc_i]
186
+ def searchable_sorts_for(field_name)
187
+ return [] unless respond_to?(:sortable_field?) && sortable_field?(field_name)
188
+ return [] unless respond_to?(:sortable_scopes)
189
+
190
+ sortable_scopes
191
+ .select { |scope| scope.to_s.start_with?("sort_#{field_name}_") }
192
+ end
193
+
194
+ private
195
+
196
+ # Sanitizza e normalizza parametri predicates
197
+ # IMPORTANTE: Non usa più to_unsafe_h per sicurezza. Se passi ActionController::Parameters,
198
+ # devi chiamare .permit! o .to_h esplicitamente nel controller prima di passarlo qui.
199
+ def sanitize_predicates(predicates)
200
+ return {} if predicates.nil?
201
+
202
+ # Se è un ActionController::Parameters, converti a hash
203
+ # Ma richiedi che sia già stato permesso (non bypassa strong parameters)
204
+ if defined?(ActionController::Parameters) && predicates.is_a?(ActionController::Parameters)
205
+ unless predicates.permitted?
206
+ raise ArgumentError,
207
+ "ActionController::Parameters must be explicitly permitted before passing to search. " \
208
+ "Use .permit! or .to_h in your controller."
209
+ end
210
+ predicates = predicates.to_h
211
+ end
212
+
213
+ predicates.deep_symbolize_keys
214
+ end
215
+
216
+ # Applica predicati AND
217
+ def apply_predicates(scope, predicates)
218
+ predicates.each do |predicate_scope, value|
219
+ # Validate scope exists
220
+ unless respond_to?(:predicable_scope?) && predicable_scope?(predicate_scope)
221
+ raise InvalidPredicateError,
222
+ "Invalid predicate scope: #{predicate_scope}. " \
223
+ "Available predicable scopes: #{predicable_scopes.to_a.join(', ')}"
224
+ end
225
+
226
+ # Skip nil/blank values (but not false)
227
+ next if value.nil?
228
+ next if value.respond_to?(:empty?) && value.empty?
229
+
230
+ # Apply scope
231
+ scope = if value == true || value == "true"
232
+ scope.public_send(predicate_scope)
233
+ elsif value.is_a?(Array)
234
+ # Splat array values for predicates like _between that expect multiple args
235
+ scope.public_send(predicate_scope, *value)
236
+ else
237
+ scope.public_send(predicate_scope, value)
238
+ end
239
+ end
240
+
241
+ scope
242
+ end
243
+
244
+ # Applica condizioni OR
245
+ def apply_or_conditions(scope, or_conditions_array)
246
+ # Build OR query usando Arel
247
+ or_relation = or_conditions_array.map do |condition_hash|
248
+ temp_scope = all
249
+ condition_hash.symbolize_keys.each do |predicate_scope, value|
250
+ unless respond_to?(:predicable_scope?) && predicable_scope?(predicate_scope)
251
+ raise InvalidPredicateError,
252
+ "Invalid predicate in OR condition: #{predicate_scope}. " \
253
+ "Available predicable scopes: #{predicable_scopes.to_a.join(', ')}"
254
+ end
255
+
256
+ temp_scope = if value == true || value == "true"
257
+ temp_scope.public_send(predicate_scope)
258
+ elsif value.is_a?(Array)
259
+ # Splat array values for predicates like _between that expect multiple args
260
+ temp_scope.public_send(predicate_scope, *value)
261
+ else
262
+ temp_scope.public_send(predicate_scope, value)
263
+ end
264
+ end
265
+ temp_scope
266
+ end.reduce { |union, condition| union.or(condition) }
267
+
268
+ scope.merge(or_relation)
269
+ end
270
+
271
+ # Applica ordinamenti
272
+ def apply_orders(scope, orders_array)
273
+ Array(orders_array).each do |order_scope|
274
+ order_scope = order_scope.to_sym
275
+
276
+ # Validate scope exists
277
+ unless respond_to?(:sortable_scope?) && sortable_scope?(order_scope)
278
+ raise InvalidOrderError,
279
+ "Invalid order scope: #{order_scope}. " \
280
+ "Available sortable scopes: #{sortable_scopes.to_a.join(', ')}"
281
+ end
282
+
283
+ # Apply scope
284
+ scope = scope.public_send(order_scope)
285
+ end
286
+
287
+ scope
288
+ end
289
+
290
+ # Applica paginazione
291
+ def apply_pagination(scope, pagination_params)
292
+ page = pagination_params[:page]&.to_i || 1
293
+ per_page = pagination_params[:per_page]&.to_i
294
+
295
+ # Validate page >= 1 (always, even without per_page)
296
+ raise InvalidPaginationError, "page must be >= 1" if page < 1
297
+
298
+ # DoS protection: limita il numero massimo di pagina per evitare offset enormi
299
+ # Default 10000, configurabile tramite searchable config
300
+ max_page = searchable_config[:max_page] || 10_000
301
+ if page > max_page
302
+ raise InvalidPaginationError,
303
+ "page must be <= #{max_page} (DoS protection). " \
304
+ "Configure max_page in searchable block to change this limit."
305
+ end
306
+
307
+ # If per_page is not provided, return scope without LIMIT
308
+ return scope if per_page.nil?
309
+
310
+ # Validate per_page >= 1
311
+ raise InvalidPaginationError, "per_page must be >= 1" if per_page < 1
312
+
313
+ # Respect max_per_page limit if configured
314
+ if searchable_config[:max_per_page].present?
315
+ per_page = [ per_page, searchable_config[:max_per_page] ].min
316
+ end
317
+
318
+ offset = (page - 1) * per_page
319
+ scope.limit(per_page).offset(offset)
320
+ end
321
+
322
+ # Valida che i predicati obbligatori della security siano presenti con valori validi
323
+ def validate_security(security_name, predicates)
324
+ # Converti security_name a symbol
325
+ security_name = security_name.to_sym
326
+
327
+ # Verifica che la security esista nella configurazione
328
+ securities_config = searchable_config[:securities] || {}
329
+
330
+ unless securities_config.key?(security_name)
331
+ raise InvalidSecurityError,
332
+ "Unknown security: #{security_name}. " \
333
+ "Available securities: #{securities_config.keys.join(', ')}"
334
+ end
335
+
336
+ # Ottieni i predicati obbligatori per questa security
337
+ required_predicates = securities_config[security_name]
338
+
339
+ # Verifica che tutti i predicati obbligatori siano presenti CON valori validi
340
+ missing_or_blank = required_predicates.reject do |pred|
341
+ value = predicates[pred]
342
+ # Il predicato deve esistere, non essere nil, e non essere empty
343
+ predicates.key?(pred) &&
344
+ !value.nil? &&
345
+ !(value.respond_to?(:empty?) && value.empty?)
346
+ end
347
+
348
+ if missing_or_blank.any?
349
+ raise InvalidSecurityError,
350
+ "Security :#{security_name} requires the following predicates with valid values: #{missing_or_blank.join(', ')}. " \
351
+ "These predicates must be present and have non-blank values in the search parameters."
352
+ end
353
+ end
354
+
355
+ # Valida che le condizioni OR rispettino i requisiti di security
356
+ # Le condizioni OR non devono permettere di bypassare le regole di security
357
+ def validate_security_in_or_conditions(security_name, or_conditions_array)
358
+ return if or_conditions_array.blank?
359
+
360
+ # Verifica che la security esista nella configurazione
361
+ securities_config = searchable_config[:securities] || {}
362
+
363
+ unless securities_config.key?(security_name)
364
+ raise InvalidSecurityError,
365
+ "Unknown security: #{security_name}. " \
366
+ "Available securities: #{securities_config.keys.join(', ')}"
367
+ end
368
+
369
+ # Ottieni i predicati obbligatori per questa security
370
+ required_predicates = securities_config[security_name]
371
+
372
+ # Valida ogni condizione OR
373
+ or_conditions_array.each_with_index do |condition_hash, index|
374
+ condition_hash = condition_hash.deep_symbolize_keys
375
+
376
+ # Verifica che tutti i predicati obbligatori siano presenti in questa condizione OR
377
+ missing_or_blank = required_predicates.reject do |pred|
378
+ value = condition_hash[pred]
379
+ # Il predicato deve esistere, non essere nil, e non essere empty
380
+ condition_hash.key?(pred) &&
381
+ !value.nil? &&
382
+ !(value.respond_to?(:empty?) && value.empty?)
383
+ end
384
+
385
+ if missing_or_blank.any?
386
+ raise InvalidSecurityError,
387
+ "Security :#{security_name} violation in OR condition ##{index + 1}. " \
388
+ "Each OR condition must include the following predicates with valid values: #{missing_or_blank.join(', ')}. " \
389
+ "This prevents bypassing security rules through OR logic."
390
+ end
391
+ end
392
+ end
393
+
394
+ # Valida la complessità della query per prevenire attacchi DoS
395
+ def validate_query_complexity(predicates, or_conditions)
396
+ # Limita il numero totale di predicati (default 100, configurabile)
397
+ max_predicates = searchable_config[:max_predicates] || 100
398
+ total_predicates = predicates.size
399
+
400
+ if total_predicates > max_predicates
401
+ raise ArgumentError,
402
+ "Query too complex: #{total_predicates} predicates exceeds maximum of #{max_predicates}. " \
403
+ "Configure max_predicates in searchable block to change this limit."
404
+ end
405
+
406
+ # Limita il numero di condizioni OR (default 50, configurabile)
407
+ return unless or_conditions.present?
408
+
409
+ max_or_conditions = searchable_config[:max_or_conditions] || 50
410
+ or_count = Array(or_conditions).size
411
+
412
+ if or_count > max_or_conditions
413
+ raise ArgumentError,
414
+ "Query too complex: #{or_count} OR conditions exceeds maximum of #{max_or_conditions}. " \
415
+ "Configure max_or_conditions in searchable block to change this limit."
416
+ end
417
+
418
+ # Conta anche i predicati dentro ogni condizione OR
419
+ or_predicates_count = Array(or_conditions).sum { |cond| cond.size }
420
+ total_with_or = total_predicates + or_predicates_count
421
+
422
+ if total_with_or > max_predicates
423
+ raise ArgumentError,
424
+ "Query too complex: #{total_with_or} total predicates (including OR conditions) exceeds maximum of #{max_predicates}. " \
425
+ "Configure max_predicates in searchable block to change this limit."
426
+ end
427
+ end
428
+ end
429
+
430
+ # Metodi di istanza
431
+
432
+ # Ritorna metadata di ricerca per questo record
433
+ #
434
+ # @return [Hash] Hash con informazioni sui campi ricercabili e ordinabili
435
+ #
436
+ # @example
437
+ # article.search_metadata
438
+ # # => {
439
+ # # searchable_fields: [:title, :status, ...],
440
+ # # sortable_fields: [:title, :view_count, ...],
441
+ # # available_predicates: { title: [:eq, :cont, ...], ... },
442
+ # # available_sorts: { title: [:sort_title_asc, ...], ... },
443
+ # # pagination: { per_page: 25, max_per_page: 100 }
444
+ # # }
445
+ def search_metadata
446
+ {
447
+ searchable_fields: self.class.searchable_fields.to_a,
448
+ sortable_fields: self.class.respond_to?(:sortable_fields) ? self.class.sortable_fields.to_a : [],
449
+ available_predicates: self.class.searchable_fields.each_with_object({}) do |field, hash|
450
+ hash[field] = self.class.searchable_predicates_for(field)
451
+ end,
452
+ available_sorts: (self.class.respond_to?(:sortable_fields) ? self.class.sortable_fields : []).each_with_object({}) do |field, hash|
453
+ hash[field] = self.class.searchable_sorts_for(field)
454
+ end,
455
+ pagination: {
456
+ per_page: self.class.searchable_config[:per_page],
457
+ max_per_page: self.class.searchable_config[:max_per_page]
458
+ }
459
+ }
460
+ end
461
+ end
462
+
463
+ # Internal configurator for searchable DSL
464
+ class SearchableConfigurator
465
+ attr_reader :config
466
+
467
+ def initialize(model_class)
468
+ @model_class = model_class
469
+ @config = {
470
+ default_order: nil,
471
+ per_page: nil,
472
+ max_per_page: nil,
473
+ max_page: nil,
474
+ max_predicates: nil,
475
+ max_or_conditions: nil,
476
+ securities: {}
477
+ }
478
+ end
479
+
480
+ def default_order(order_scopes)
481
+ order_scopes = Array(order_scopes)
482
+ @config[:default_order] = order_scopes.map(&:to_sym)
483
+ end
484
+
485
+ def per_page(count)
486
+ @config[:per_page] = count.to_i
487
+ end
488
+
489
+ def max_per_page(count)
490
+ @config[:max_per_page] = count.to_i
491
+ end
492
+
493
+ # DoS protection: limite massimo numero di pagina (default 10000)
494
+ def max_page(count)
495
+ @config[:max_page] = count.to_i
496
+ end
497
+
498
+ # DoS protection: limite massimo numero di predicati totali (default 100)
499
+ def max_predicates(count)
500
+ @config[:max_predicates] = count.to_i
501
+ end
502
+
503
+ # DoS protection: limite massimo numero di condizioni OR (default 50)
504
+ def max_or_conditions(count)
505
+ @config[:max_or_conditions] = count.to_i
506
+ end
507
+
508
+ def security(name, predicates_array)
509
+ name = name.to_sym
510
+ predicates_array = Array(predicates_array).map(&:to_sym)
511
+
512
+ # Valida che i predicati richiesti siano simboli validi
513
+ if predicates_array.empty?
514
+ raise ArgumentError, "Security :#{name} must have at least one required predicate"
515
+ end
516
+
517
+ @config[:securities][name] = predicates_array
518
+ end
519
+
520
+ def to_h
521
+ @config
522
+ end
523
+ end
524
+ end