better_model 1.0.0 → 1.2.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.
@@ -115,7 +115,7 @@ module BetterModel
115
115
 
116
116
  # Applica default scope SOLO se configurato
117
117
  if archivable_config[:skip_archived_by_default]
118
- default_scope -> { not_archived }
118
+ default_scope -> { where(archived_at: nil) }
119
119
  end
120
120
  end
121
121
  end
@@ -124,19 +124,19 @@ module BetterModel
124
124
  #
125
125
  # @return [ActiveRecord::Relation]
126
126
  def archived_only
127
- raise NotEnabledError unless archivable_enabled?
127
+ raise ArchivableNotEnabledError unless archivable_enabled?
128
128
  unscoped.archived
129
129
  end
130
130
 
131
131
  # Helper: alias per archived_at_today
132
132
  def archived_today
133
- raise NotEnabledError unless archivable_enabled?
133
+ raise ArchivableNotEnabledError unless archivable_enabled?
134
134
  archived_at_today
135
135
  end
136
136
 
137
137
  # Helper: alias per archived_at_this_week
138
138
  def archived_this_week
139
- raise NotEnabledError unless archivable_enabled?
139
+ raise ArchivableNotEnabledError unless archivable_enabled?
140
140
  archived_at_this_week
141
141
  end
142
142
 
@@ -145,7 +145,7 @@ module BetterModel
145
145
  # @param duration [ActiveSupport::Duration] Durata (es: 7.days)
146
146
  # @return [ActiveRecord::Relation]
147
147
  def archived_recently(duration = 7.days)
148
- raise NotEnabledError unless archivable_enabled?
148
+ raise ArchivableNotEnabledError unless archivable_enabled?
149
149
  archived_at_within(duration)
150
150
  end
151
151
 
@@ -164,10 +164,10 @@ module BetterModel
164
164
  # @param by [Integer, Object] ID utente o oggetto user (opzionale)
165
165
  # @param reason [String] Motivo dell'archiviazione (opzionale)
166
166
  # @return [self]
167
- # @raise [NotEnabledError] se archivable non è attivo
167
+ # @raise [ArchivableNotEnabledError] se archivable non è attivo
168
168
  # @raise [AlreadyArchivedError] se già archiviato
169
169
  def archive!(by: nil, reason: nil)
170
- raise NotEnabledError unless self.class.archivable_enabled?
170
+ raise ArchivableNotEnabledError unless self.class.archivable_enabled?
171
171
  raise AlreadyArchivedError, "Record is already archived" if archived?
172
172
 
173
173
  self.archived_at = Time.current
@@ -186,10 +186,10 @@ module BetterModel
186
186
  # Ripristina record archiviato
187
187
  #
188
188
  # @return [self]
189
- # @raise [NotEnabledError] se archivable non è attivo
189
+ # @raise [ArchivableNotEnabledError] se archivable non è attivo
190
190
  # @raise [NotArchivedError] se non archiviato
191
191
  def restore!
192
- raise NotEnabledError unless self.class.archivable_enabled?
192
+ raise ArchivableNotEnabledError unless self.class.archivable_enabled?
193
193
  raise NotArchivedError, "Record is not archived" unless archived?
194
194
 
195
195
  self.archived_at = nil
@@ -241,7 +241,7 @@ module BetterModel
241
241
  class AlreadyArchivedError < ArchivableError; end
242
242
  class NotArchivedError < ArchivableError; end
243
243
 
244
- class NotEnabledError < ArchivableError
244
+ class ArchivableNotEnabledError < ArchivableError
245
245
  def initialize(msg = nil)
246
246
  super(msg || "Archivable is not enabled. Add 'archivable do...end' to your model.")
247
247
  end
@@ -58,6 +58,9 @@ module BetterModel
58
58
  column = columns_hash[field_name.to_s]
59
59
  next unless column
60
60
 
61
+ # Base predicates available for all column types
62
+ define_base_predicates(field_name, column.type)
63
+
61
64
  case column.type
62
65
  when :string, :text
63
66
  define_string_predicates(field_name)
@@ -68,18 +71,14 @@ module BetterModel
68
71
  when :date, :datetime, :time, :timestamp
69
72
  define_date_predicates(field_name)
70
73
  when :jsonb, :json
71
- # JSONB/JSON: base predicates + PostgreSQL-specific
72
- define_base_predicates(field_name)
74
+ # JSONB/JSON: PostgreSQL-specific predicates
73
75
  define_postgresql_jsonb_predicates(field_name)
74
76
  else
75
77
  # Check for array columns (PostgreSQL)
76
78
  if column.respond_to?(:array?) && column.array?
77
- define_base_predicates(field_name)
78
79
  define_postgresql_array_predicates(field_name)
79
- else
80
- # Default: genera solo predicati base
81
- define_base_predicates(field_name)
82
80
  end
81
+ # Unknown types only get base predicates
83
82
  end
84
83
  end
85
84
  end
@@ -143,7 +142,7 @@ module BetterModel
143
142
  end
144
143
 
145
144
  # Genera predicati base: _eq, _not_eq, _present
146
- def define_base_predicates(field_name)
145
+ def define_base_predicates(field_name, column_type = nil)
147
146
  table = arel_table
148
147
  field = table[field_name]
149
148
 
@@ -151,24 +150,27 @@ module BetterModel
151
150
  scope :"#{field_name}_eq", ->(value) { where(field.eq(value)) }
152
151
  scope :"#{field_name}_not_eq", ->(value) { where(field.not_eq(value)) }
153
152
 
154
- # Presence
155
- scope :"#{field_name}_present", -> { where(field.not_eq(nil)) }
153
+ # Presence - skip for string/text types as they get specialized version
154
+ # String types need to check for both nil and empty string
155
+ unless [ :string, :text ].include?(column_type)
156
+ scope :"#{field_name}_present", -> { where(field.not_eq(nil)) }
157
+ end
156
158
 
157
- register_predicable_scopes(
158
- :"#{field_name}_eq",
159
- :"#{field_name}_not_eq",
160
- :"#{field_name}_present"
161
- )
159
+ scopes_to_register = [ :"#{field_name}_eq", :"#{field_name}_not_eq" ]
160
+ scopes_to_register << :"#{field_name}_present" unless [ :string, :text ].include?(column_type)
161
+
162
+ register_predicable_scopes(*scopes_to_register)
162
163
  end
163
164
 
164
- # Genera predicati per campi stringa (14 scope)
165
+ # Genera predicati per campi stringa (12 scope)
166
+ # Base predicates (_eq, _not_eq) are defined separately
167
+ # _present is defined here to handle both nil and empty strings
165
168
  def define_string_predicates(field_name)
166
169
  table = arel_table
167
170
  field = table[field_name]
168
171
 
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
+ # String-specific presence check (checks both nil and empty string)
173
+ scope :"#{field_name}_present", -> { where(field.not_eq(nil).and(field.not_eq(""))) }
172
174
 
173
175
  # Pattern matching (4)
174
176
  scope :"#{field_name}_matches", ->(pattern) { where(field.matches(pattern)) }
@@ -203,14 +205,11 @@ module BetterModel
203
205
  scope :"#{field_name}_in", ->(values) { where(field.in(Array(values))) }
204
206
  scope :"#{field_name}_not_in", ->(values) { where.not(field.in(Array(values))) }
205
207
 
206
- # Presence (3)
207
- scope :"#{field_name}_present", -> { where(field.not_eq(nil).and(field.not_eq(""))) }
208
+ # Presence (2) - _present is overridden above for string-specific behavior
208
209
  scope :"#{field_name}_blank", -> { where(field.eq(nil).or(field.eq(""))) }
209
210
  scope :"#{field_name}_null", -> { where(field.eq(nil)) }
210
211
 
211
212
  register_predicable_scopes(
212
- :"#{field_name}_eq",
213
- :"#{field_name}_not_eq",
214
213
  :"#{field_name}_matches",
215
214
  :"#{field_name}_start",
216
215
  :"#{field_name}_end",
@@ -226,14 +225,13 @@ module BetterModel
226
225
  )
227
226
  end
228
227
 
229
- # Genera predicati per campi numerici (11 scope)
228
+ # Genera predicati per campi numerici (8 scope)
229
+ # Base predicates (_eq, _not_eq, _present) are defined separately
230
230
  def define_numeric_predicates(field_name)
231
231
  table = arel_table
232
232
  field = table[field_name]
233
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)) }
234
+ # Comparison (4)
237
235
  scope :"#{field_name}_lt", ->(value) { where(field.lt(value)) }
238
236
  scope :"#{field_name}_lteq", ->(value) { where(field.lteq(value)) }
239
237
  scope :"#{field_name}_gt", ->(value) { where(field.gt(value)) }
@@ -247,12 +245,7 @@ module BetterModel
247
245
  scope :"#{field_name}_in", ->(values) { where(field.in(Array(values))) }
248
246
  scope :"#{field_name}_not_in", ->(values) { where.not(field.in(Array(values))) }
249
247
 
250
- # Presence (1)
251
- scope :"#{field_name}_present", -> { where(field.not_eq(nil)) }
252
-
253
248
  register_predicable_scopes(
254
- :"#{field_name}_eq",
255
- :"#{field_name}_not_eq",
256
249
  :"#{field_name}_lt",
257
250
  :"#{field_name}_lteq",
258
251
  :"#{field_name}_gt",
@@ -265,32 +258,27 @@ module BetterModel
265
258
  )
266
259
  end
267
260
 
268
- # Genera predicati per campi booleani (5 scope)
261
+ # Genera predicati per campi booleani (2 scope)
262
+ # Base predicates (_eq, _not_eq, _present) are defined separately
269
263
  def define_boolean_predicates(field_name)
270
264
  table = arel_table
271
265
  field = table[field_name]
272
266
 
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
267
  # Boolean shortcuts (2)
278
268
  scope :"#{field_name}_true", -> { where(field.eq(true)) }
279
269
  scope :"#{field_name}_false", -> { where(field.eq(false)) }
280
270
 
281
- # Presence (1)
282
- scope :"#{field_name}_present", -> { where(field.not_eq(nil)) }
283
-
284
271
  register_predicable_scopes(
285
- :"#{field_name}_eq",
286
- :"#{field_name}_not_eq",
287
272
  :"#{field_name}_true",
288
- :"#{field_name}_false",
289
- :"#{field_name}_present"
273
+ :"#{field_name}_false"
290
274
  )
291
275
  end
292
276
 
293
277
  # Genera predicati per campi array PostgreSQL (3 scope)
278
+ #
279
+ # NOTA: Questo metodo non è coperto da test automatici perché richiede
280
+ # PostgreSQL. I test vengono eseguiti su SQLite per performance.
281
+ # Testare manualmente su PostgreSQL con: rails console RAILS_ENV=test
294
282
  def define_postgresql_array_predicates(field_name)
295
283
  return unless postgresql_adapter?
296
284
 
@@ -343,6 +331,10 @@ module BetterModel
343
331
  end
344
332
 
345
333
  # Genera predicati per campi JSONB PostgreSQL (4 scope)
334
+ #
335
+ # NOTA: Questo metodo non è coperto da test automatici perché richiede
336
+ # PostgreSQL con supporto JSONB. I test vengono eseguiti su SQLite per performance.
337
+ # Testare manualmente su PostgreSQL con: rails console RAILS_ENV=test
346
338
  def define_postgresql_jsonb_predicates(field_name)
347
339
  return unless postgresql_adapter?
348
340
 
@@ -391,14 +383,13 @@ module BetterModel
391
383
  )
392
384
  end
393
385
 
394
- # Genera predicati per campi data/datetime (22 scope)
386
+ # Genera predicati per campi data/datetime (17 scope)
387
+ # Base predicates (_eq, _not_eq, _present) are defined separately
395
388
  def define_date_predicates(field_name)
396
389
  table = arel_table
397
390
  field = table[field_name]
398
391
 
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)) }
392
+ # Comparison (4)
402
393
  scope :"#{field_name}_lt", ->(value) { where(field.lt(value)) }
403
394
  scope :"#{field_name}_lteq", ->(value) { where(field.lteq(value)) }
404
395
  scope :"#{field_name}_gt", ->(value) { where(field.gt(value)) }
@@ -440,15 +431,12 @@ module BetterModel
440
431
  where(field.gteq(time_ago))
441
432
  }
442
433
 
443
- # Presence (4)
444
- scope :"#{field_name}_present", -> { where(field.not_eq(nil)) }
434
+ # Presence (3) - _present is defined in base predicates
445
435
  scope :"#{field_name}_blank", -> { where(field.eq(nil)) }
446
436
  scope :"#{field_name}_null", -> { where(field.eq(nil)) }
447
437
  scope :"#{field_name}_not_null", -> { where(field.not_eq(nil)) }
448
438
 
449
439
  register_predicable_scopes(
450
- :"#{field_name}_eq",
451
- :"#{field_name}_not_eq",
452
440
  :"#{field_name}_lt",
453
441
  :"#{field_name}_lteq",
454
442
  :"#{field_name}_gt",
@@ -465,7 +453,6 @@ module BetterModel
465
453
  :"#{field_name}_past",
466
454
  :"#{field_name}_future",
467
455
  :"#{field_name}_within",
468
- :"#{field_name}_present",
469
456
  :"#{field_name}_blank",
470
457
  :"#{field_name}_null",
471
458
  :"#{field_name}_not_null"
@@ -188,6 +188,10 @@ module BetterModel
188
188
  end
189
189
 
190
190
  # Genera SQL per gestione NULL multi-database
191
+ #
192
+ # NOTA: Il blocco MySQL else qui sotto non è coperto da test automatici
193
+ # perché i test vengono eseguiti su SQLite. Testare manualmente su MySQL
194
+ # con: rails console RAILS_ENV=test
191
195
  def nulls_order_sql(field_name, direction, nulls_position)
192
196
  quoted_field = connection.quote_column_name(field_name)
193
197
 
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterModel
4
+ # StateTransition - Base ActiveRecord model for state transition history
5
+ #
6
+ # Questo è un modello abstract. Le classi concrete vengono generate dinamicamente
7
+ # per ogni tabella (state_transitions, order_transitions, etc.).
8
+ #
9
+ # Schema della tabella:
10
+ # t.string :transitionable_type, null: false
11
+ # t.integer :transitionable_id, null: false
12
+ # t.string :event, null: false
13
+ # t.string :from_state, null: false
14
+ # t.string :to_state, null: false
15
+ # t.json :metadata
16
+ # t.datetime :created_at, null: false
17
+ #
18
+ # Utilizzo:
19
+ # # Tutte le transizioni di un modello
20
+ # order.state_transitions
21
+ #
22
+ # # Query globali (tramite classi dinamiche)
23
+ # BetterModel::StateTransitions.for_model(Order)
24
+ # BetterModel::OrderTransitions.by_event(:confirm)
25
+ #
26
+ class StateTransition < ActiveRecord::Base
27
+ # Default table name (can be overridden by dynamic subclasses)
28
+ self.table_name = "state_transitions"
29
+
30
+ # Polymorphic association
31
+ belongs_to :transitionable, polymorphic: true
32
+
33
+ # Validations
34
+ validates :event, :from_state, :to_state, presence: true
35
+
36
+ # Scopes
37
+
38
+ # Scope per modello specifico
39
+ #
40
+ # @param model_class [Class] Classe del modello
41
+ # @return [ActiveRecord::Relation]
42
+ #
43
+ scope :for_model, ->(model_class) {
44
+ where(transitionable_type: model_class.name)
45
+ }
46
+
47
+ # Scope per evento specifico
48
+ #
49
+ # @param event [Symbol, String] Nome dell'evento
50
+ # @return [ActiveRecord::Relation]
51
+ #
52
+ scope :by_event, ->(event) {
53
+ where(event: event.to_s)
54
+ }
55
+
56
+ # Scope per stato di partenza
57
+ #
58
+ # @param state [Symbol, String] Stato di partenza
59
+ # @return [ActiveRecord::Relation]
60
+ #
61
+ scope :from_state, ->(state) {
62
+ where(from_state: state.to_s)
63
+ }
64
+
65
+ # Scope per stato di arrivo
66
+ #
67
+ # @param state [Symbol, String] Stato di arrivo
68
+ # @return [ActiveRecord::Relation]
69
+ #
70
+ scope :to_state, ->(state) {
71
+ where(to_state: state.to_s)
72
+ }
73
+
74
+ # Scope per transizioni recenti
75
+ #
76
+ # @param duration [ActiveSupport::Duration] Durata (es. 7.days)
77
+ # @return [ActiveRecord::Relation]
78
+ #
79
+ scope :recent, ->(duration = 7.days) {
80
+ where("created_at >= ?", duration.ago)
81
+ }
82
+
83
+ # Scope per transizioni in un periodo
84
+ #
85
+ # @param start_time [Time, Date] Inizio periodo
86
+ # @param end_time [Time, Date] Fine periodo
87
+ # @return [ActiveRecord::Relation]
88
+ #
89
+ scope :between, ->(start_time, end_time) {
90
+ where(created_at: start_time..end_time)
91
+ }
92
+
93
+ # Metodi di istanza
94
+
95
+ # Formatted description della transizione
96
+ #
97
+ # @return [String]
98
+ #
99
+ def description
100
+ "#{transitionable_type}##{transitionable_id}: #{from_state} -> #{to_state} (#{event})"
101
+ end
102
+
103
+ # Alias per retrocompatibilità
104
+ alias_method :to_s, :description
105
+ end
106
+ end