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.
@@ -0,0 +1,498 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Traceable - Change tracking con audit trail per modelli Rails
4
+ #
5
+ # Questo concern permette di tracciare automaticamente i cambiamenti ai record,
6
+ # mantenendo uno storico completo con timestamp, autore e motivazione.
7
+ #
8
+ # SETUP RAPIDO:
9
+ # # Opzione 1: Generator automatico (raccomandato)
10
+ # rails g better_model:traceable Article --with-reason
11
+ # rails db:migrate
12
+ #
13
+ # # Opzione 2: La migration better_model_versions è già nel gem
14
+ # rails db:migrate
15
+ #
16
+ # APPROCCIO OPT-IN: Il tracking non è attivo automaticamente. Devi chiamare
17
+ # esplicitamente `traceable do...end` nel tuo modello per attivarlo.
18
+ #
19
+ # REQUISITI DATABASE:
20
+ # - better_model_versions table (inclusa nel gem)
21
+ #
22
+ # Esempio di utilizzo:
23
+ # class Article < ApplicationRecord
24
+ # include BetterModel
25
+ #
26
+ # # Attiva traceable (opt-in)
27
+ # traceable do
28
+ # track :status, :title, :published_at # Campi da tracciare
29
+ # end
30
+ # end
31
+ #
32
+ # Utilizzo:
33
+ # # Tracking automatico
34
+ # article.update!(status: "published", updated_by_id: user.id, updated_reason: "Approved")
35
+ #
36
+ # # Query versioni
37
+ # article.versions # Tutte le versioni
38
+ # article.changes_for(:status) # Cambiamenti per un campo
39
+ # article.audit_trail # Storico formattato
40
+ #
41
+ # # Time-travel
42
+ # article.as_of(3.days.ago) # Stato a una data specifica
43
+ #
44
+ # # Rollback
45
+ # article.rollback_to(version) # Ripristina a versione precedente
46
+ #
47
+ # # Scopes per query su cambiamenti
48
+ # Article.changed_by(user.id) # Modifiche di un utente
49
+ # Article.changed_between(start, end) # Modifiche in un periodo
50
+ # Article.status_changed_from("draft").to("published") # Transizioni specifiche
51
+ #
52
+ module BetterModel
53
+ module Traceable
54
+ extend ActiveSupport::Concern
55
+
56
+ # Thread-safe mutex for dynamic class creation
57
+ CLASS_CREATION_MUTEX = Mutex.new
58
+
59
+ included do
60
+ # Validazione ActiveRecord
61
+ unless ancestors.include?(ActiveRecord::Base)
62
+ raise ArgumentError, "BetterModel::Traceable can only be included in ActiveRecord models"
63
+ end
64
+
65
+ # Configurazione traceable (opt-in)
66
+ class_attribute :traceable_enabled, default: false
67
+ class_attribute :traceable_config, default: {}.freeze
68
+ class_attribute :traceable_fields, default: [].freeze
69
+ class_attribute :traceable_table_name, default: nil
70
+ class_attribute :_traceable_setup_done, default: false
71
+ end
72
+
73
+ class_methods do
74
+ # DSL per attivare e configurare traceable (OPT-IN)
75
+ #
76
+ # @example Attivazione base
77
+ # traceable do
78
+ # track :status, :title
79
+ # end
80
+ #
81
+ def traceable(&block)
82
+ # Attiva traceable
83
+ self.traceable_enabled = true
84
+
85
+ # Configura se passato un blocco
86
+ if block_given?
87
+ configurator = TraceableConfigurator.new(self)
88
+ configurator.instance_eval(&block)
89
+ self.traceable_config = configurator.to_h.freeze
90
+ self.traceable_fields = configurator.fields.freeze
91
+ self.traceable_table_name = configurator.table_name
92
+ end
93
+
94
+ # Set default table name if not configured
95
+ self.traceable_table_name ||= "#{model_name.singular.underscore}_versions"
96
+
97
+ # Setup association and callbacks only once
98
+ return if self._traceable_setup_done
99
+
100
+ self._traceable_setup_done = true
101
+
102
+ # Create a dynamic Version class for this table if needed
103
+ version_class = create_version_class_for_table(traceable_table_name)
104
+
105
+ # Setup association
106
+ # NOTE: We DON'T use dependent: :destroy because we want to preserve
107
+ # version history even after the record is destroyed (audit trail)
108
+ has_many :versions,
109
+ -> { order(created_at: :desc) },
110
+ as: :item,
111
+ class_name: version_class.name,
112
+ foreign_key: :item_id
113
+
114
+ # Setup callbacks per tracking automatico
115
+ after_create :create_version_on_create
116
+ after_update :create_version_on_update
117
+ before_destroy :create_version_on_destroy
118
+ end
119
+
120
+ # Verifica se traceable è attivo
121
+ #
122
+ # @return [Boolean]
123
+ def traceable_enabled?
124
+ traceable_enabled == true
125
+ end
126
+
127
+ # Find records changed by a specific user
128
+ #
129
+ # @param user_id [Integer] User ID
130
+ # @return [ActiveRecord::Relation]
131
+ def changed_by(user_id)
132
+ raise NotEnabledError unless traceable_enabled?
133
+
134
+ joins(:versions).where(traceable_table_name => { updated_by_id: user_id }).distinct
135
+ end
136
+
137
+ # Find records changed between two timestamps
138
+ #
139
+ # @param start_time [Time, Date] Start time
140
+ # @param end_time [Time, Date] End time
141
+ # @return [ActiveRecord::Relation]
142
+ def changed_between(start_time, end_time)
143
+ raise NotEnabledError unless traceable_enabled?
144
+
145
+ joins(:versions).where(traceable_table_name => { created_at: start_time..end_time }).distinct
146
+ end
147
+
148
+ # Query builder for field-specific changes
149
+ #
150
+ # @param field [Symbol] Field name
151
+ # @return [ChangeQuery]
152
+ def field_changed(field)
153
+ raise NotEnabledError unless traceable_enabled?
154
+
155
+ ChangeQuery.new(self, field)
156
+ end
157
+
158
+ # Syntactic sugar: Article.status_changed_from(...)
159
+ def method_missing(method_name, *args, &block)
160
+ if method_name.to_s =~ /^(.+)_changed_from$/
161
+ field = Regexp.last_match(1).to_sym
162
+ return field_changed(field).from(args.first) if traceable_fields.include?(field)
163
+ end
164
+
165
+ super
166
+ end
167
+
168
+ def respond_to_missing?(method_name, include_private = false)
169
+ if method_name.to_s =~ /^(.+)_changed_from$/
170
+ field = Regexp.last_match(1).to_sym
171
+ return true if traceable_fields.include?(field)
172
+ end
173
+
174
+ super
175
+ end
176
+
177
+ private
178
+
179
+ # Create or retrieve a Version class for the given table name
180
+ #
181
+ # Thread-safe implementation using mutex to prevent race conditions
182
+ # when multiple threads try to create the same class simultaneously.
183
+ #
184
+ # @param table_name [String] Table name for versions
185
+ # @return [Class] Version class
186
+ def create_version_class_for_table(table_name)
187
+ # Create a unique class name based on table name
188
+ class_name = "#{table_name.camelize.singularize}"
189
+
190
+ # Fast path: check if class already exists (no lock needed)
191
+ if BetterModel.const_defined?(class_name, false)
192
+ return BetterModel.const_get(class_name)
193
+ end
194
+
195
+ # Slow path: acquire lock and create class
196
+ CLASS_CREATION_MUTEX.synchronize do
197
+ # Double-check after acquiring lock (another thread may have created it)
198
+ if BetterModel.const_defined?(class_name, false)
199
+ return BetterModel.const_get(class_name)
200
+ end
201
+
202
+ # Create new Version class dynamically
203
+ version_class = Class.new(BetterModel::Version) do
204
+ self.table_name = table_name
205
+ end
206
+
207
+ # Register the class in BetterModel namespace
208
+ BetterModel.const_set(class_name, version_class)
209
+ version_class
210
+ end
211
+ end
212
+ end
213
+
214
+ # Metodi di istanza
215
+
216
+ # Get changes for a specific field across all versions
217
+ #
218
+ # @param field [Symbol] Field name
219
+ # @return [Array<Hash>] Array of changes with :before, :after, :at, :by
220
+ def changes_for(field)
221
+ raise NotEnabledError unless self.class.traceable_enabled?
222
+
223
+ versions.select { |v| v.changed?(field) }.map do |version|
224
+ change = version.change_for(field)
225
+ {
226
+ before: change[:before],
227
+ after: change[:after],
228
+ at: version.created_at,
229
+ by: version.updated_by_id,
230
+ reason: version.updated_reason
231
+ }
232
+ end
233
+ end
234
+
235
+ # Get formatted audit trail
236
+ #
237
+ # @return [Array<Hash>] Full audit trail
238
+ def audit_trail
239
+ raise NotEnabledError unless self.class.traceable_enabled?
240
+
241
+ versions.map do |version|
242
+ {
243
+ event: version.event,
244
+ changes: version.object_changes || {},
245
+ at: version.created_at,
246
+ by: version.updated_by_id,
247
+ reason: version.updated_reason
248
+ }
249
+ end
250
+ end
251
+
252
+ # Reconstruct object state at a specific point in time
253
+ #
254
+ # @param timestamp [Time, Date] Point in time
255
+ # @return [self] Reconstructed object (not saved)
256
+ def as_of(timestamp)
257
+ raise NotEnabledError unless self.class.traceable_enabled?
258
+
259
+ # Get all versions up to timestamp, ordered from oldest to newest
260
+ relevant_versions = versions.where("created_at <= ?", timestamp).order(created_at: :asc)
261
+
262
+ # Start with a blank object
263
+ reconstructed = self.class.new
264
+
265
+ # Apply each version's "after" value in chronological order
266
+ relevant_versions.each do |version|
267
+ next unless version.object_changes
268
+
269
+ version.object_changes.each do |field, (_before_value, after_value)|
270
+ reconstructed.send("#{field}=", after_value) if reconstructed.respond_to?("#{field}=")
271
+ end
272
+ end
273
+
274
+ reconstructed.id = id
275
+ reconstructed.readonly!
276
+ reconstructed
277
+ end
278
+
279
+ # Rollback to a specific version
280
+ #
281
+ # @param version [BetterModel::Version, Integer] Version or version ID
282
+ # @param updated_by_id [Integer] User ID performing rollback
283
+ # @param updated_reason [String] Reason for rollback
284
+ # @return [self]
285
+ def rollback_to(version, updated_by_id: nil, updated_reason: nil)
286
+ raise NotEnabledError unless self.class.traceable_enabled?
287
+
288
+ version = versions.find(version) if version.is_a?(Integer)
289
+
290
+ raise ActiveRecord::RecordNotFound, "Version not found" unless version
291
+ raise ActiveRecord::RecordNotFound, "Version does not belong to this record" unless version.item == self
292
+
293
+ # Apply changes from version
294
+ if version.object_changes
295
+ version.object_changes.each do |field, (before_value, _after_value)|
296
+ send("#{field}=", before_value) if respond_to?("#{field}=")
297
+ end
298
+ end
299
+
300
+ # Save with tracking
301
+ self.updated_by_id = updated_by_id if respond_to?(:updated_by_id=)
302
+ self.updated_reason = updated_reason || "Rolled back to version #{version.id}" if respond_to?(:updated_reason=)
303
+
304
+ save!(validate: false)
305
+ self
306
+ end
307
+
308
+ # Override as_json to include audit trail
309
+ #
310
+ # @param options [Hash] Options
311
+ # @option options [Boolean] :include_audit_trail Include full audit trail
312
+ # @return [Hash]
313
+ def as_json(options = {})
314
+ result = super
315
+
316
+ if options[:include_audit_trail] && self.class.traceable_enabled?
317
+ result["audit_trail"] = audit_trail
318
+ end
319
+
320
+ result
321
+ end
322
+
323
+ private
324
+
325
+ # Create version on record creation
326
+ def create_version_on_create
327
+ return unless self.class.traceable_enabled?
328
+
329
+ create_version("created", tracked_changes)
330
+ end
331
+
332
+ # Create version on record update
333
+ def create_version_on_update
334
+ return unless self.class.traceable_enabled?
335
+
336
+ changes = tracked_changes
337
+ return if changes.empty?
338
+
339
+ create_version("updated", changes)
340
+ end
341
+
342
+ # Create version on record destruction
343
+ def create_version_on_destroy
344
+ return unless self.class.traceable_enabled?
345
+
346
+ create_version("destroyed", tracked_final_state)
347
+ end
348
+
349
+ # Get tracked changes (only for configured fields)
350
+ def tracked_changes
351
+ return {} if self.class.traceable_fields.empty?
352
+
353
+ if saved_changes.any?
354
+ # After save: use saved_changes
355
+ saved_changes.slice(*self.class.traceable_fields.map(&:to_s))
356
+ else
357
+ # Before save: use changes
358
+ changes.slice(*self.class.traceable_fields.map(&:to_s))
359
+ end
360
+ end
361
+
362
+ # Get final state for destroyed records
363
+ def tracked_final_state
364
+ self.class.traceable_fields.each_with_object({}) do |field, hash|
365
+ hash[field.to_s] = [ send(field), nil ]
366
+ end
367
+ end
368
+
369
+ # Create a version record
370
+ def create_version(event_type, changes_hash)
371
+ versions.create!(
372
+ event: event_type,
373
+ object_changes: changes_hash,
374
+ updated_by_id: try(:updated_by_id),
375
+ updated_reason: try(:updated_reason)
376
+ )
377
+ end
378
+ end
379
+
380
+ # Errori custom
381
+ class TraceableError < StandardError; end
382
+
383
+ class NotEnabledError < TraceableError
384
+ def initialize(msg = nil)
385
+ super(msg || "Traceable is not enabled. Add 'traceable do...end' to your model.")
386
+ end
387
+ end
388
+
389
+ # Configurator per traceable DSL
390
+ class TraceableConfigurator
391
+ attr_reader :fields, :table_name
392
+
393
+ def initialize(model_class)
394
+ @model_class = model_class
395
+ @fields = []
396
+ @table_name = nil
397
+ end
398
+
399
+ # Specify which fields to track
400
+ #
401
+ # @param field_names [Array<Symbol>] Field names to track
402
+ def track(*field_names)
403
+ @fields.concat(field_names)
404
+ end
405
+
406
+ # Specify custom table name for versions
407
+ #
408
+ # @param name [String, Symbol] Table name
409
+ def versions_table(name)
410
+ @table_name = name.to_s
411
+ end
412
+
413
+ def to_h
414
+ { fields: @fields, table_name: @table_name }
415
+ end
416
+ end
417
+
418
+ # Query builder for change-specific queries
419
+ class ChangeQuery
420
+ def initialize(model_class, field)
421
+ @model_class = model_class
422
+ @field = field.to_s
423
+ @from_value = nil
424
+ @to_value = nil
425
+ @table_name = model_class.traceable_table_name
426
+ end
427
+
428
+ def from(value)
429
+ @from_value = value
430
+ self
431
+ end
432
+
433
+ def to(value)
434
+ @to_value = value
435
+ execute_query
436
+ end
437
+
438
+ private
439
+
440
+ # Check if database supports JSON/JSONB queries
441
+ def postgres?
442
+ ActiveRecord::Base.connection.adapter_name.downcase == "postgresql"
443
+ end
444
+
445
+ def mysql?
446
+ adapter = ActiveRecord::Base.connection.adapter_name.downcase
447
+ adapter.include?("mysql") || adapter == "trilogy"
448
+ end
449
+
450
+ def execute_query
451
+ # Base query
452
+ query = @model_class.joins(:versions)
453
+
454
+ # NOTA: I blocchi PostgreSQL e MySQL qui sotto non sono coperti da test
455
+ # automatici perché i test vengono eseguiti su SQLite per performance.
456
+ # Testare manualmente su PostgreSQL/MySQL con: rails console RAILS_ENV=test
457
+
458
+ # PostgreSQL: Use JSONB operators for better performance
459
+ if postgres?
460
+ query = query.where("#{@table_name}.object_changes ? :field", field: @field)
461
+
462
+ if @from_value
463
+ query = query.where("#{@table_name}.object_changes->>'#{@field}' LIKE ?", "%#{@from_value}%")
464
+ end
465
+
466
+ if @to_value
467
+ query = query.where("#{@table_name}.object_changes->>'#{@field}' LIKE ?", "%#{@to_value}%")
468
+ end
469
+ # MySQL 5.7+: Use JSON_EXTRACT
470
+ elsif mysql?
471
+ query = query.where("JSON_EXTRACT(#{@table_name}.object_changes, '$.#{@field}') IS NOT NULL")
472
+
473
+ if @from_value
474
+ query = query.where("JSON_EXTRACT(#{@table_name}.object_changes, '$.#{@field}') LIKE ?", "%#{@from_value}%")
475
+ end
476
+
477
+ if @to_value
478
+ query = query.where("JSON_EXTRACT(#{@table_name}.object_changes, '$.#{@field}') LIKE ?", "%#{@to_value}%")
479
+ end
480
+ # SQLite or fallback: Use text-based search (limited functionality)
481
+ else
482
+ Rails.logger.warn "Traceable field-specific queries may have limited functionality on #{ActiveRecord::Base.connection.adapter_name}"
483
+
484
+ query = query.where("#{@table_name}.object_changes LIKE ?", "%\"#{@field}\"%")
485
+
486
+ if @from_value
487
+ query = query.where("#{@table_name}.object_changes LIKE ?", "%#{@from_value}%")
488
+ end
489
+
490
+ if @to_value
491
+ query = query.where("#{@table_name}.object_changes LIKE ?", "%#{@to_value}%")
492
+ end
493
+ end
494
+
495
+ query.distinct
496
+ end
497
+ end
498
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterModel
4
+ module Validatable
5
+ # Validator per business rules custom
6
+ #
7
+ # Permette di eseguire metodi custom come validatori, delegando la logica
8
+ # di validazione complessa a metodi del modello.
9
+ #
10
+ # Il metodo della business rule deve aggiungere errori tramite `errors.add`
11
+ # se la validazione fallisce.
12
+ #
13
+ # Esempio:
14
+ # validates_with BusinessRuleValidator, rule_name: :valid_category
15
+ #
16
+ # # Nel modello:
17
+ # def valid_category
18
+ # unless Category.exists?(id: category_id)
19
+ # errors.add(:category_id, "must be a valid category")
20
+ # end
21
+ # end
22
+ #
23
+ class BusinessRuleValidator < ActiveModel::Validator
24
+ def initialize(options)
25
+ super
26
+
27
+ @rule_name = options[:rule_name]
28
+
29
+ unless @rule_name
30
+ raise ArgumentError, "BusinessRuleValidator requires :rule_name option"
31
+ end
32
+ end
33
+
34
+ def validate(record)
35
+ # Verifica che il metodo esista
36
+ unless record.respond_to?(@rule_name, true)
37
+ raise NoMethodError, "Business rule method '#{@rule_name}' not found in #{record.class.name}. " \
38
+ "Define it in your model: def #{@rule_name}; ...; end"
39
+ end
40
+
41
+ # Esegui il metodo della business rule
42
+ # Il metodo stesso è responsabile di aggiungere errori tramite errors.add
43
+ record.send(@rule_name)
44
+ end
45
+ end
46
+ end
47
+ end