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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +312 -0
- data/Rakefile +12 -0
- data/lib/better_model/archivable.rb +272 -0
- data/lib/better_model/permissible.rb +151 -0
- data/lib/better_model/predicable.rb +481 -0
- data/lib/better_model/railtie.rb +4 -0
- data/lib/better_model/searchable.rb +524 -0
- data/lib/better_model/sortable.rb +217 -0
- data/lib/better_model/statusable.rb +154 -0
- data/lib/better_model/version.rb +3 -0
- data/lib/better_model.rb +24 -0
- data/lib/generators/better_model/archivable/archivable_generator.rb +55 -0
- data/lib/generators/better_model/archivable/templates/migration.rb.tt +26 -0
- data/lib/tasks/better_model_tasks.rake +4 -0
- metadata +82 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Permissible - Sistema di permessi dichiarativi per modelli Rails
|
|
4
|
+
#
|
|
5
|
+
# Questo concern permette di definire permessi/capacità sui modelli utilizzando un DSL
|
|
6
|
+
# semplice e dichiarativo, simile al pattern Statusable ma per le operazioni.
|
|
7
|
+
#
|
|
8
|
+
# Esempio di utilizzo:
|
|
9
|
+
# class Article < ApplicationRecord
|
|
10
|
+
# include BetterModel::Permissible
|
|
11
|
+
#
|
|
12
|
+
# permit :delete, -> { status != "published" }
|
|
13
|
+
# permit :edit, -> { is?(:draft) || is?(:scheduled) }
|
|
14
|
+
# permit :publish, -> { is?(:draft) && valid?(:publication) }
|
|
15
|
+
# permit :archive, -> { is?(:published) && created_at < 1.year.ago }
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Utilizzo:
|
|
19
|
+
# article.permit?(:delete) # => true/false
|
|
20
|
+
# article.permit_delete? # => true/false
|
|
21
|
+
# article.permit_edit? # => true/false
|
|
22
|
+
# article.permit_publish? # => true/false
|
|
23
|
+
#
|
|
24
|
+
module BetterModel
|
|
25
|
+
module Permissible
|
|
26
|
+
extend ActiveSupport::Concern
|
|
27
|
+
|
|
28
|
+
included do
|
|
29
|
+
# Registry dei permessi definiti per questa classe
|
|
30
|
+
class_attribute :permit_definitions
|
|
31
|
+
self.permit_definitions = {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class_methods do
|
|
35
|
+
# DSL per definire permessi
|
|
36
|
+
#
|
|
37
|
+
# Parametri:
|
|
38
|
+
# - permission_name: simbolo che rappresenta il permesso (es. :delete, :edit)
|
|
39
|
+
# - condition_proc: lambda o proc che definisce la condizione
|
|
40
|
+
# - block: blocco alternativo alla condition_proc
|
|
41
|
+
#
|
|
42
|
+
# Esempi:
|
|
43
|
+
# permit :delete, -> { status != "published" }
|
|
44
|
+
# permit :edit, -> { is?(:draft) }
|
|
45
|
+
# permit :publish do
|
|
46
|
+
# is?(:draft) && valid?(:publication)
|
|
47
|
+
# end
|
|
48
|
+
def permit(permission_name, condition_proc = nil, &block)
|
|
49
|
+
# Valida i parametri prima di convertire
|
|
50
|
+
raise ArgumentError, "Permission name cannot be blank" if permission_name.blank?
|
|
51
|
+
|
|
52
|
+
permission_name = permission_name.to_sym
|
|
53
|
+
condition = condition_proc || block
|
|
54
|
+
raise ArgumentError, "Condition proc or block is required" unless condition
|
|
55
|
+
raise ArgumentError, "Condition must respond to call" unless condition.respond_to?(:call)
|
|
56
|
+
|
|
57
|
+
# Registra il permesso nel registry
|
|
58
|
+
self.permit_definitions = permit_definitions.merge(permission_name => condition.freeze).freeze
|
|
59
|
+
|
|
60
|
+
# Genera il metodo dinamico permit_#{permission_name}?
|
|
61
|
+
define_permit_method(permission_name)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Lista di tutti i permessi definiti per questa classe
|
|
65
|
+
def defined_permissions
|
|
66
|
+
permit_definitions.keys
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Verifica se un permesso è definito
|
|
70
|
+
def permission_defined?(permission_name)
|
|
71
|
+
permit_definitions.key?(permission_name.to_sym)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Genera dinamicamente il metodo permit_#{permission_name}? per ogni permesso definito
|
|
77
|
+
def define_permit_method(permission_name)
|
|
78
|
+
method_name = "permit_#{permission_name}?"
|
|
79
|
+
|
|
80
|
+
# Evita di ridefinire metodi se già esistono
|
|
81
|
+
return if method_defined?(method_name)
|
|
82
|
+
|
|
83
|
+
define_method(method_name) do
|
|
84
|
+
permit?(permission_name)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Metodo generico per verificare se un permesso è garantito
|
|
90
|
+
#
|
|
91
|
+
# Parametri:
|
|
92
|
+
# - permission_name: simbolo del permesso da verificare
|
|
93
|
+
#
|
|
94
|
+
# Ritorna:
|
|
95
|
+
# - true se il permesso è garantito
|
|
96
|
+
# - false se il permesso non è garantito o non è definito
|
|
97
|
+
#
|
|
98
|
+
# Esempio:
|
|
99
|
+
# article.permit?(:delete)
|
|
100
|
+
def permit?(permission_name)
|
|
101
|
+
permission_name = permission_name.to_sym
|
|
102
|
+
condition = self.class.permit_definitions[permission_name]
|
|
103
|
+
|
|
104
|
+
# Se il permesso non è definito, ritorna false (secure by default)
|
|
105
|
+
return false unless condition
|
|
106
|
+
|
|
107
|
+
# Valuta la condizione nel contesto dell'istanza del modello
|
|
108
|
+
# Gli errori si propagano naturalmente - fail fast
|
|
109
|
+
instance_exec(&condition)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Ritorna tutti i permessi disponibili per questa istanza con i loro valori
|
|
113
|
+
#
|
|
114
|
+
# Ritorna:
|
|
115
|
+
# - Hash con chiavi simbolo (permessi) e valori booleani (garantiti/negati)
|
|
116
|
+
#
|
|
117
|
+
# Esempio:
|
|
118
|
+
# article.permissions
|
|
119
|
+
# # => { delete: true, edit: false, publish: false, archive: false }
|
|
120
|
+
def permissions
|
|
121
|
+
self.class.permit_definitions.each_with_object({}) do |(permission_name, _condition), result|
|
|
122
|
+
result[permission_name] = permit?(permission_name)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Verifica se l'istanza ha almeno un permesso garantito
|
|
127
|
+
def has_any_permission?
|
|
128
|
+
permissions.values.any?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Verifica se l'istanza ha tutti i permessi specificati garantiti
|
|
132
|
+
def has_all_permissions?(permission_names)
|
|
133
|
+
Array(permission_names).all? { |permission_name| permit?(permission_name) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Filtra una lista di permessi restituendo solo quelli garantiti
|
|
137
|
+
def granted_permissions(permission_names)
|
|
138
|
+
Array(permission_names).select { |permission_name| permit?(permission_name) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Override di as_json per includere automaticamente i permessi se richiesto
|
|
142
|
+
def as_json(options = {})
|
|
143
|
+
result = super
|
|
144
|
+
|
|
145
|
+
# Include i permessi se esplicitamente richiesto, converting symbol keys to strings
|
|
146
|
+
result["permissions"] = permissions.transform_keys(&:to_s) if options[:include_permissions]
|
|
147
|
+
|
|
148
|
+
result
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Predicable - Sistema di filtri/predicati dichiarativo per modelli Rails
|
|
4
|
+
#
|
|
5
|
+
# Questo concern permette di definire predicati di ricerca sui modelli utilizzando un DSL
|
|
6
|
+
# semplice e dichiarativo che genera automaticamente scope basati sul tipo di colonna.
|
|
7
|
+
#
|
|
8
|
+
# Esempio di utilizzo:
|
|
9
|
+
# class Article < ApplicationRecord
|
|
10
|
+
# include BetterModel::Predicable
|
|
11
|
+
#
|
|
12
|
+
# predicates :title, :status, :view_count, :published_at, :featured
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# Utilizzo:
|
|
16
|
+
# Article.title_eq("Ruby on Rails") # WHERE title = 'Ruby on Rails'
|
|
17
|
+
# Article.title_i_cont("rails") # WHERE LOWER(title) LIKE '%rails%'
|
|
18
|
+
# Article.view_count_gt(100) # WHERE view_count > 100
|
|
19
|
+
# Article.published_at_lteq(Date.today) # WHERE published_at <= '2025-10-29'
|
|
20
|
+
# Article.featured_true # WHERE featured = TRUE
|
|
21
|
+
# Article.status_in(["draft", "published"]) # WHERE status IN ('draft', 'published')
|
|
22
|
+
#
|
|
23
|
+
module BetterModel
|
|
24
|
+
module Predicable
|
|
25
|
+
extend ActiveSupport::Concern
|
|
26
|
+
|
|
27
|
+
included do
|
|
28
|
+
# Valida che sia incluso solo in modelli ActiveRecord
|
|
29
|
+
unless ancestors.include?(ActiveRecord::Base)
|
|
30
|
+
raise ArgumentError, "BetterModel::Predicable can only be included in ActiveRecord models"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Registry dei campi predicable definiti per questa classe
|
|
34
|
+
class_attribute :predicable_fields, default: Set.new
|
|
35
|
+
# Registry degli scope predicable generati
|
|
36
|
+
class_attribute :predicable_scopes, default: Set.new
|
|
37
|
+
# Registry dei predicati complessi custom
|
|
38
|
+
class_attribute :complex_predicates_registry, default: {}.freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class_methods do
|
|
42
|
+
# DSL per definire campi predicable
|
|
43
|
+
#
|
|
44
|
+
# Genera automaticamente scope di filtro basati sul tipo di colonna:
|
|
45
|
+
# - String: _eq, _not_eq, _matches, _start, _end, _cont, _not_cont, _i_cont, _not_i_cont, _in, _not_in, _present, _blank, _null
|
|
46
|
+
# - Numeric: _eq, _not_eq, _lt, _lteq, _gt, _gteq, _in, _not_in, _present
|
|
47
|
+
# - Boolean: _eq, _not_eq, _true, _false, _present
|
|
48
|
+
# - Date: _eq, _not_eq, _lt, _lteq, _gt, _gteq, _in, _not_in, _present, _blank, _null, _not_null
|
|
49
|
+
#
|
|
50
|
+
# Esempio:
|
|
51
|
+
# predicates :title, :view_count, :published_at, :featured
|
|
52
|
+
def predicates(*field_names)
|
|
53
|
+
field_names.each do |field_name|
|
|
54
|
+
validate_predicable_field!(field_name)
|
|
55
|
+
register_predicable_field(field_name)
|
|
56
|
+
|
|
57
|
+
# Auto-rileva tipo e genera scope appropriati
|
|
58
|
+
column = columns_hash[field_name.to_s]
|
|
59
|
+
next unless column
|
|
60
|
+
|
|
61
|
+
case column.type
|
|
62
|
+
when :string, :text
|
|
63
|
+
define_string_predicates(field_name)
|
|
64
|
+
when :integer, :decimal, :float, :bigint
|
|
65
|
+
define_numeric_predicates(field_name)
|
|
66
|
+
when :boolean
|
|
67
|
+
define_boolean_predicates(field_name)
|
|
68
|
+
when :date, :datetime, :time, :timestamp
|
|
69
|
+
define_date_predicates(field_name)
|
|
70
|
+
when :jsonb, :json
|
|
71
|
+
# JSONB/JSON: base predicates + PostgreSQL-specific
|
|
72
|
+
define_base_predicates(field_name)
|
|
73
|
+
define_postgresql_jsonb_predicates(field_name)
|
|
74
|
+
else
|
|
75
|
+
# Check for array columns (PostgreSQL)
|
|
76
|
+
if column.respond_to?(:array?) && column.array?
|
|
77
|
+
define_base_predicates(field_name)
|
|
78
|
+
define_postgresql_array_predicates(field_name)
|
|
79
|
+
else
|
|
80
|
+
# Default: genera solo predicati base
|
|
81
|
+
define_base_predicates(field_name)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Registra un predicato complesso custom
|
|
88
|
+
#
|
|
89
|
+
# Permette di definire filtri complessi che combinano più condizioni
|
|
90
|
+
# o utilizzano logica custom non coperta dai predicati standard.
|
|
91
|
+
#
|
|
92
|
+
# Esempio:
|
|
93
|
+
# register_complex_predicate :recent_popular do |days = 7, min_views = 100|
|
|
94
|
+
# where("published_at >= ? AND view_count >= ?", days.days.ago, min_views)
|
|
95
|
+
# end
|
|
96
|
+
#
|
|
97
|
+
# Article.recent_popular(7, 100)
|
|
98
|
+
def register_complex_predicate(name, &block)
|
|
99
|
+
raise ArgumentError, "Block required for complex predicate" unless block_given?
|
|
100
|
+
|
|
101
|
+
# Registra nel registry
|
|
102
|
+
self.complex_predicates_registry = complex_predicates_registry.merge(name.to_sym => block).freeze
|
|
103
|
+
|
|
104
|
+
# Definisce lo scope
|
|
105
|
+
scope name, block
|
|
106
|
+
|
|
107
|
+
# Registra lo scope
|
|
108
|
+
register_predicable_scopes(name)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Verifica se un campo è stato registrato come predicable
|
|
112
|
+
def predicable_field?(field_name)
|
|
113
|
+
predicable_fields.include?(field_name.to_sym)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Verifica se uno scope predicable è stato generato
|
|
117
|
+
def predicable_scope?(scope_name)
|
|
118
|
+
predicable_scopes.include?(scope_name.to_sym)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Verifica se un predicato complesso è stato registrato
|
|
122
|
+
def complex_predicate?(name)
|
|
123
|
+
complex_predicates_registry.key?(name.to_sym)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# Valida che il campo esista nella tabella
|
|
129
|
+
def validate_predicable_field!(field_name)
|
|
130
|
+
unless column_names.include?(field_name.to_s)
|
|
131
|
+
raise ArgumentError, "Invalid field name: #{field_name}. Field does not exist in #{table_name}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Registra un campo nel registry predicable_fields
|
|
136
|
+
def register_predicable_field(field_name)
|
|
137
|
+
self.predicable_fields = (predicable_fields + [ field_name.to_sym ]).to_set.freeze
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Registra scope nel registry predicable_scopes
|
|
141
|
+
def register_predicable_scopes(*scope_names)
|
|
142
|
+
self.predicable_scopes = (predicable_scopes + scope_names.map(&:to_sym)).to_set.freeze
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Genera predicati base: _eq, _not_eq, _present
|
|
146
|
+
def define_base_predicates(field_name)
|
|
147
|
+
table = arel_table
|
|
148
|
+
field = table[field_name]
|
|
149
|
+
|
|
150
|
+
# Equality
|
|
151
|
+
scope :"#{field_name}_eq", ->(value) { where(field.eq(value)) }
|
|
152
|
+
scope :"#{field_name}_not_eq", ->(value) { where(field.not_eq(value)) }
|
|
153
|
+
|
|
154
|
+
# Presence
|
|
155
|
+
scope :"#{field_name}_present", -> { where(field.not_eq(nil)) }
|
|
156
|
+
|
|
157
|
+
register_predicable_scopes(
|
|
158
|
+
:"#{field_name}_eq",
|
|
159
|
+
:"#{field_name}_not_eq",
|
|
160
|
+
:"#{field_name}_present"
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Genera predicati per campi stringa (14 scope)
|
|
165
|
+
def define_string_predicates(field_name)
|
|
166
|
+
table = arel_table
|
|
167
|
+
field = table[field_name]
|
|
168
|
+
|
|
169
|
+
# Comparison (2)
|
|
170
|
+
scope :"#{field_name}_eq", ->(value) { where(field.eq(value)) }
|
|
171
|
+
scope :"#{field_name}_not_eq", ->(value) { where(field.not_eq(value)) }
|
|
172
|
+
|
|
173
|
+
# Pattern matching (4)
|
|
174
|
+
scope :"#{field_name}_matches", ->(pattern) { where(field.matches(pattern)) }
|
|
175
|
+
scope :"#{field_name}_start", ->(prefix) {
|
|
176
|
+
sanitized = ActiveRecord::Base.sanitize_sql_like(prefix.to_s)
|
|
177
|
+
where(field.matches("#{sanitized}%"))
|
|
178
|
+
}
|
|
179
|
+
scope :"#{field_name}_end", ->(suffix) {
|
|
180
|
+
sanitized = ActiveRecord::Base.sanitize_sql_like(suffix.to_s)
|
|
181
|
+
where(field.matches("%#{sanitized}"))
|
|
182
|
+
}
|
|
183
|
+
scope :"#{field_name}_cont", ->(substring) {
|
|
184
|
+
sanitized = ActiveRecord::Base.sanitize_sql_like(substring.to_s)
|
|
185
|
+
where(field.matches("%#{sanitized}%"))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Case-insensitive pattern matching (2)
|
|
189
|
+
scope :"#{field_name}_i_cont", ->(substring) {
|
|
190
|
+
sanitized = ActiveRecord::Base.sanitize_sql_like(substring.to_s.downcase)
|
|
191
|
+
where(Arel::Nodes::NamedFunction.new("LOWER", [ field ]).matches("%#{sanitized}%"))
|
|
192
|
+
}
|
|
193
|
+
scope :"#{field_name}_not_cont", ->(substring) {
|
|
194
|
+
sanitized = ActiveRecord::Base.sanitize_sql_like(substring.to_s)
|
|
195
|
+
where.not(field.matches("%#{sanitized}%"))
|
|
196
|
+
}
|
|
197
|
+
scope :"#{field_name}_not_i_cont", ->(substring) {
|
|
198
|
+
sanitized = ActiveRecord::Base.sanitize_sql_like(substring.to_s.downcase)
|
|
199
|
+
where.not(Arel::Nodes::NamedFunction.new("LOWER", [ field ]).matches("%#{sanitized}%"))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
# Array operations (2)
|
|
203
|
+
scope :"#{field_name}_in", ->(values) { where(field.in(Array(values))) }
|
|
204
|
+
scope :"#{field_name}_not_in", ->(values) { where.not(field.in(Array(values))) }
|
|
205
|
+
|
|
206
|
+
# Presence (3)
|
|
207
|
+
scope :"#{field_name}_present", -> { where(field.not_eq(nil).and(field.not_eq(""))) }
|
|
208
|
+
scope :"#{field_name}_blank", -> { where(field.eq(nil).or(field.eq(""))) }
|
|
209
|
+
scope :"#{field_name}_null", -> { where(field.eq(nil)) }
|
|
210
|
+
|
|
211
|
+
register_predicable_scopes(
|
|
212
|
+
:"#{field_name}_eq",
|
|
213
|
+
:"#{field_name}_not_eq",
|
|
214
|
+
:"#{field_name}_matches",
|
|
215
|
+
:"#{field_name}_start",
|
|
216
|
+
:"#{field_name}_end",
|
|
217
|
+
:"#{field_name}_cont",
|
|
218
|
+
:"#{field_name}_i_cont",
|
|
219
|
+
:"#{field_name}_not_cont",
|
|
220
|
+
:"#{field_name}_not_i_cont",
|
|
221
|
+
:"#{field_name}_in",
|
|
222
|
+
:"#{field_name}_not_in",
|
|
223
|
+
:"#{field_name}_present",
|
|
224
|
+
:"#{field_name}_blank",
|
|
225
|
+
:"#{field_name}_null"
|
|
226
|
+
)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Genera predicati per campi numerici (11 scope)
|
|
230
|
+
def define_numeric_predicates(field_name)
|
|
231
|
+
table = arel_table
|
|
232
|
+
field = table[field_name]
|
|
233
|
+
|
|
234
|
+
# Comparison (6)
|
|
235
|
+
scope :"#{field_name}_eq", ->(value) { where(field.eq(value)) }
|
|
236
|
+
scope :"#{field_name}_not_eq", ->(value) { where(field.not_eq(value)) }
|
|
237
|
+
scope :"#{field_name}_lt", ->(value) { where(field.lt(value)) }
|
|
238
|
+
scope :"#{field_name}_lteq", ->(value) { where(field.lteq(value)) }
|
|
239
|
+
scope :"#{field_name}_gt", ->(value) { where(field.gt(value)) }
|
|
240
|
+
scope :"#{field_name}_gteq", ->(value) { where(field.gteq(value)) }
|
|
241
|
+
|
|
242
|
+
# Range queries (2)
|
|
243
|
+
scope :"#{field_name}_between", ->(min, max) { where(field.between(min..max)) }
|
|
244
|
+
scope :"#{field_name}_not_between", ->(min, max) { where.not(field.between(min..max)) }
|
|
245
|
+
|
|
246
|
+
# Array operations (2)
|
|
247
|
+
scope :"#{field_name}_in", ->(values) { where(field.in(Array(values))) }
|
|
248
|
+
scope :"#{field_name}_not_in", ->(values) { where.not(field.in(Array(values))) }
|
|
249
|
+
|
|
250
|
+
# Presence (1)
|
|
251
|
+
scope :"#{field_name}_present", -> { where(field.not_eq(nil)) }
|
|
252
|
+
|
|
253
|
+
register_predicable_scopes(
|
|
254
|
+
:"#{field_name}_eq",
|
|
255
|
+
:"#{field_name}_not_eq",
|
|
256
|
+
:"#{field_name}_lt",
|
|
257
|
+
:"#{field_name}_lteq",
|
|
258
|
+
:"#{field_name}_gt",
|
|
259
|
+
:"#{field_name}_gteq",
|
|
260
|
+
:"#{field_name}_between",
|
|
261
|
+
:"#{field_name}_not_between",
|
|
262
|
+
:"#{field_name}_in",
|
|
263
|
+
:"#{field_name}_not_in",
|
|
264
|
+
:"#{field_name}_present"
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Genera predicati per campi booleani (5 scope)
|
|
269
|
+
def define_boolean_predicates(field_name)
|
|
270
|
+
table = arel_table
|
|
271
|
+
field = table[field_name]
|
|
272
|
+
|
|
273
|
+
# Comparison (2)
|
|
274
|
+
scope :"#{field_name}_eq", ->(value) { where(field.eq(value)) }
|
|
275
|
+
scope :"#{field_name}_not_eq", ->(value) { where(field.not_eq(value)) }
|
|
276
|
+
|
|
277
|
+
# Boolean shortcuts (2)
|
|
278
|
+
scope :"#{field_name}_true", -> { where(field.eq(true)) }
|
|
279
|
+
scope :"#{field_name}_false", -> { where(field.eq(false)) }
|
|
280
|
+
|
|
281
|
+
# Presence (1)
|
|
282
|
+
scope :"#{field_name}_present", -> { where(field.not_eq(nil)) }
|
|
283
|
+
|
|
284
|
+
register_predicable_scopes(
|
|
285
|
+
:"#{field_name}_eq",
|
|
286
|
+
:"#{field_name}_not_eq",
|
|
287
|
+
:"#{field_name}_true",
|
|
288
|
+
:"#{field_name}_false",
|
|
289
|
+
:"#{field_name}_present"
|
|
290
|
+
)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Genera predicati per campi array PostgreSQL (3 scope)
|
|
294
|
+
def define_postgresql_array_predicates(field_name)
|
|
295
|
+
return unless postgresql_adapter?
|
|
296
|
+
|
|
297
|
+
table = arel_table
|
|
298
|
+
field = table[field_name]
|
|
299
|
+
column = columns_hash[field_name.to_s]
|
|
300
|
+
|
|
301
|
+
# Array overlap (&&)
|
|
302
|
+
# Usa Arel per generare SQL sicuro con parameter binding
|
|
303
|
+
scope :"#{field_name}_overlaps", ->(array) {
|
|
304
|
+
sanitized_array = Array(array).map { |v| connection.quote(v) }.join(",")
|
|
305
|
+
where(
|
|
306
|
+
Arel::Nodes::InfixOperation.new(
|
|
307
|
+
"&&",
|
|
308
|
+
field,
|
|
309
|
+
Arel::Nodes::SqlLiteral.new("ARRAY[#{sanitized_array}]::#{column.sql_type}")
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
# Array contains (@>)
|
|
315
|
+
scope :"#{field_name}_contains", ->(value) {
|
|
316
|
+
sanitized_array = Array(value).map { |v| connection.quote(v) }.join(",")
|
|
317
|
+
where(
|
|
318
|
+
Arel::Nodes::InfixOperation.new(
|
|
319
|
+
"@>",
|
|
320
|
+
field,
|
|
321
|
+
Arel::Nodes::SqlLiteral.new("ARRAY[#{sanitized_array}]::#{column.sql_type}")
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
# Array contained by (<@)
|
|
327
|
+
scope :"#{field_name}_contained_by", ->(array) {
|
|
328
|
+
sanitized_array = Array(array).map { |v| connection.quote(v) }.join(",")
|
|
329
|
+
where(
|
|
330
|
+
Arel::Nodes::InfixOperation.new(
|
|
331
|
+
"<@",
|
|
332
|
+
field,
|
|
333
|
+
Arel::Nodes::SqlLiteral.new("ARRAY[#{sanitized_array}]::#{column.sql_type}")
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
register_predicable_scopes(
|
|
339
|
+
:"#{field_name}_overlaps",
|
|
340
|
+
:"#{field_name}_contains",
|
|
341
|
+
:"#{field_name}_contained_by"
|
|
342
|
+
)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Genera predicati per campi JSONB PostgreSQL (4 scope)
|
|
346
|
+
def define_postgresql_jsonb_predicates(field_name)
|
|
347
|
+
return unless postgresql_adapter?
|
|
348
|
+
|
|
349
|
+
table = arel_table
|
|
350
|
+
field = table[field_name]
|
|
351
|
+
quoted_table = connection.quote_table_name(table_name)
|
|
352
|
+
quoted_field = connection.quote_column_name(field_name)
|
|
353
|
+
|
|
354
|
+
# JSONB has key (?)
|
|
355
|
+
# Usa named bind per evitare SQL injection
|
|
356
|
+
scope :"#{field_name}_has_key", ->(key) {
|
|
357
|
+
where("#{quoted_table}.#{quoted_field} ? :key", key: connection.quote(key.to_s))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
# JSONB has any key (?|)
|
|
361
|
+
# Sanifica ogni chiave nell'array prima di passare alla query
|
|
362
|
+
scope :"#{field_name}_has_any_key", ->(keys) {
|
|
363
|
+
sanitized_keys = Array(keys).map { |k| connection.quote(k.to_s) }.join(",")
|
|
364
|
+
where("#{quoted_table}.#{quoted_field} ?| ARRAY[#{sanitized_keys}]")
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
# JSONB has all keys (?&)
|
|
368
|
+
scope :"#{field_name}_has_all_keys", ->(keys) {
|
|
369
|
+
sanitized_keys = Array(keys).map { |k| connection.quote(k.to_s) }.join(",")
|
|
370
|
+
where("#{quoted_table}.#{quoted_field} ?& ARRAY[#{sanitized_keys}]")
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
# JSONB contains (@>)
|
|
374
|
+
# Usa Arel per operazione @> con proper escaping
|
|
375
|
+
scope :"#{field_name}_jsonb_contains", ->(hash_or_value) {
|
|
376
|
+
json_value = hash_or_value.is_a?(String) ? hash_or_value : hash_or_value.to_json
|
|
377
|
+
where(
|
|
378
|
+
Arel::Nodes::InfixOperation.new(
|
|
379
|
+
"@>",
|
|
380
|
+
field,
|
|
381
|
+
Arel::Nodes::Quoted.new(json_value)
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
register_predicable_scopes(
|
|
387
|
+
:"#{field_name}_has_key",
|
|
388
|
+
:"#{field_name}_has_any_key",
|
|
389
|
+
:"#{field_name}_has_all_keys",
|
|
390
|
+
:"#{field_name}_jsonb_contains"
|
|
391
|
+
)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Genera predicati per campi data/datetime (22 scope)
|
|
395
|
+
def define_date_predicates(field_name)
|
|
396
|
+
table = arel_table
|
|
397
|
+
field = table[field_name]
|
|
398
|
+
|
|
399
|
+
# Comparison (6)
|
|
400
|
+
scope :"#{field_name}_eq", ->(value) { where(field.eq(value)) }
|
|
401
|
+
scope :"#{field_name}_not_eq", ->(value) { where(field.not_eq(value)) }
|
|
402
|
+
scope :"#{field_name}_lt", ->(value) { where(field.lt(value)) }
|
|
403
|
+
scope :"#{field_name}_lteq", ->(value) { where(field.lteq(value)) }
|
|
404
|
+
scope :"#{field_name}_gt", ->(value) { where(field.gt(value)) }
|
|
405
|
+
scope :"#{field_name}_gteq", ->(value) { where(field.gteq(value)) }
|
|
406
|
+
|
|
407
|
+
# Range queries (2)
|
|
408
|
+
scope :"#{field_name}_between", ->(min, max) { where(field.between(min..max)) }
|
|
409
|
+
scope :"#{field_name}_not_between", ->(min, max) { where.not(field.between(min..max)) }
|
|
410
|
+
|
|
411
|
+
# Array operations (2)
|
|
412
|
+
scope :"#{field_name}_in", ->(values) { where(field.in(Array(values))) }
|
|
413
|
+
scope :"#{field_name}_not_in", ->(values) { where.not(field.in(Array(values))) }
|
|
414
|
+
|
|
415
|
+
# Date convenience shortcuts (8)
|
|
416
|
+
scope :"#{field_name}_today", -> {
|
|
417
|
+
where(field.between(Date.current.beginning_of_day..Date.current.end_of_day))
|
|
418
|
+
}
|
|
419
|
+
scope :"#{field_name}_yesterday", -> {
|
|
420
|
+
where(field.between(1.day.ago.beginning_of_day..1.day.ago.end_of_day))
|
|
421
|
+
}
|
|
422
|
+
scope :"#{field_name}_this_week", -> {
|
|
423
|
+
where(field.gteq(Date.current.beginning_of_week))
|
|
424
|
+
}
|
|
425
|
+
scope :"#{field_name}_this_month", -> {
|
|
426
|
+
where(field.gteq(Date.current.beginning_of_month))
|
|
427
|
+
}
|
|
428
|
+
scope :"#{field_name}_this_year", -> {
|
|
429
|
+
where(field.gteq(Date.current.beginning_of_year))
|
|
430
|
+
}
|
|
431
|
+
scope :"#{field_name}_past", -> {
|
|
432
|
+
where(field.lt(Time.current))
|
|
433
|
+
}
|
|
434
|
+
scope :"#{field_name}_future", -> {
|
|
435
|
+
where(field.gt(Time.current))
|
|
436
|
+
}
|
|
437
|
+
scope :"#{field_name}_within", ->(duration) {
|
|
438
|
+
# Auto-detect: ActiveSupport::Duration or numeric (days)
|
|
439
|
+
time_ago = duration.respond_to?(:ago) ? duration.ago : duration.to_i.days.ago
|
|
440
|
+
where(field.gteq(time_ago))
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
# Presence (4)
|
|
444
|
+
scope :"#{field_name}_present", -> { where(field.not_eq(nil)) }
|
|
445
|
+
scope :"#{field_name}_blank", -> { where(field.eq(nil)) }
|
|
446
|
+
scope :"#{field_name}_null", -> { where(field.eq(nil)) }
|
|
447
|
+
scope :"#{field_name}_not_null", -> { where(field.not_eq(nil)) }
|
|
448
|
+
|
|
449
|
+
register_predicable_scopes(
|
|
450
|
+
:"#{field_name}_eq",
|
|
451
|
+
:"#{field_name}_not_eq",
|
|
452
|
+
:"#{field_name}_lt",
|
|
453
|
+
:"#{field_name}_lteq",
|
|
454
|
+
:"#{field_name}_gt",
|
|
455
|
+
:"#{field_name}_gteq",
|
|
456
|
+
:"#{field_name}_between",
|
|
457
|
+
:"#{field_name}_not_between",
|
|
458
|
+
:"#{field_name}_in",
|
|
459
|
+
:"#{field_name}_not_in",
|
|
460
|
+
:"#{field_name}_today",
|
|
461
|
+
:"#{field_name}_yesterday",
|
|
462
|
+
:"#{field_name}_this_week",
|
|
463
|
+
:"#{field_name}_this_month",
|
|
464
|
+
:"#{field_name}_this_year",
|
|
465
|
+
:"#{field_name}_past",
|
|
466
|
+
:"#{field_name}_future",
|
|
467
|
+
:"#{field_name}_within",
|
|
468
|
+
:"#{field_name}_present",
|
|
469
|
+
:"#{field_name}_blank",
|
|
470
|
+
:"#{field_name}_null",
|
|
471
|
+
:"#{field_name}_not_null"
|
|
472
|
+
)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Verifica se il database adapter è PostgreSQL
|
|
476
|
+
def postgresql_adapter?
|
|
477
|
+
connection.adapter_name.match?(/PostgreSQL/i)
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
end
|