better_model 1.0.0 → 1.1.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,446 @@
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
+ included do
57
+ # Validazione ActiveRecord
58
+ unless ancestors.include?(ActiveRecord::Base)
59
+ raise ArgumentError, "BetterModel::Traceable can only be included in ActiveRecord models"
60
+ end
61
+
62
+ # Configurazione traceable (opt-in)
63
+ class_attribute :traceable_enabled, default: false
64
+ class_attribute :traceable_config, default: {}.freeze
65
+ class_attribute :traceable_fields, default: [].freeze
66
+ class_attribute :traceable_table_name, default: nil
67
+ class_attribute :_traceable_setup_done, default: false
68
+ end
69
+
70
+ class_methods do
71
+ # DSL per attivare e configurare traceable (OPT-IN)
72
+ #
73
+ # @example Attivazione base
74
+ # traceable do
75
+ # track :status, :title
76
+ # end
77
+ #
78
+ def traceable(&block)
79
+ # Attiva traceable
80
+ self.traceable_enabled = true
81
+
82
+ # Configura se passato un blocco
83
+ if block_given?
84
+ configurator = TraceableConfigurator.new(self)
85
+ configurator.instance_eval(&block)
86
+ self.traceable_config = configurator.to_h.freeze
87
+ self.traceable_fields = configurator.fields.freeze
88
+ self.traceable_table_name = configurator.table_name
89
+ end
90
+
91
+ # Set default table name if not configured
92
+ self.traceable_table_name ||= "#{model_name.singular.underscore}_versions"
93
+
94
+ # Setup association and callbacks only once
95
+ return if self._traceable_setup_done
96
+
97
+ self._traceable_setup_done = true
98
+
99
+ # Create a dynamic Version class for this table if needed
100
+ version_class = create_version_class_for_table(traceable_table_name)
101
+
102
+ # Setup association
103
+ # NOTE: We DON'T use dependent: :destroy because we want to preserve
104
+ # version history even after the record is destroyed (audit trail)
105
+ has_many :versions,
106
+ -> { order(created_at: :desc) },
107
+ as: :item,
108
+ class_name: version_class.name,
109
+ foreign_key: :item_id
110
+
111
+ # Setup callbacks per tracking automatico
112
+ after_create :create_version_on_create
113
+ after_update :create_version_on_update
114
+ before_destroy :create_version_on_destroy
115
+ end
116
+
117
+ # Verifica se traceable è attivo
118
+ #
119
+ # @return [Boolean]
120
+ def traceable_enabled?
121
+ traceable_enabled == true
122
+ end
123
+
124
+ # Find records changed by a specific user
125
+ #
126
+ # @param user_id [Integer] User ID
127
+ # @return [ActiveRecord::Relation]
128
+ def changed_by(user_id)
129
+ raise NotEnabledError unless traceable_enabled?
130
+
131
+ joins(:versions).where(traceable_table_name => { updated_by_id: user_id }).distinct
132
+ end
133
+
134
+ # Find records changed between two timestamps
135
+ #
136
+ # @param start_time [Time, Date] Start time
137
+ # @param end_time [Time, Date] End time
138
+ # @return [ActiveRecord::Relation]
139
+ def changed_between(start_time, end_time)
140
+ raise NotEnabledError unless traceable_enabled?
141
+
142
+ joins(:versions).where(traceable_table_name => { created_at: start_time..end_time }).distinct
143
+ end
144
+
145
+ # Query builder for field-specific changes
146
+ #
147
+ # @param field [Symbol] Field name
148
+ # @return [ChangeQuery]
149
+ def field_changed(field)
150
+ raise NotEnabledError unless traceable_enabled?
151
+
152
+ ChangeQuery.new(self, field)
153
+ end
154
+
155
+ # Syntactic sugar: Article.status_changed_from(...)
156
+ def method_missing(method_name, *args, &block)
157
+ if method_name.to_s =~ /^(.+)_changed_from$/
158
+ field = Regexp.last_match(1).to_sym
159
+ return field_changed(field).from(args.first) if traceable_fields.include?(field)
160
+ end
161
+
162
+ super
163
+ end
164
+
165
+ def respond_to_missing?(method_name, include_private = false)
166
+ if method_name.to_s =~ /^(.+)_changed_from$/
167
+ field = Regexp.last_match(1).to_sym
168
+ return true if traceable_fields.include?(field)
169
+ end
170
+
171
+ super
172
+ end
173
+
174
+ private
175
+
176
+ # Create or retrieve a Version class for the given table name
177
+ #
178
+ # @param table_name [String] Table name for versions
179
+ # @return [Class] Version class
180
+ def create_version_class_for_table(table_name)
181
+ # Create a unique class name based on table name
182
+ class_name = "#{table_name.camelize.singularize}"
183
+
184
+ # Check if class already exists in BetterModel namespace
185
+ if BetterModel.const_defined?(class_name, false)
186
+ return BetterModel.const_get(class_name)
187
+ end
188
+
189
+ # Create new Version class dynamically
190
+ version_class = Class.new(BetterModel::Version) do
191
+ self.table_name = table_name
192
+ end
193
+
194
+ # Register the class in BetterModel namespace
195
+ BetterModel.const_set(class_name, version_class)
196
+ version_class
197
+ end
198
+ end
199
+
200
+ # Metodi di istanza
201
+
202
+ # Get changes for a specific field across all versions
203
+ #
204
+ # @param field [Symbol] Field name
205
+ # @return [Array<Hash>] Array of changes with :before, :after, :at, :by
206
+ def changes_for(field)
207
+ raise NotEnabledError unless self.class.traceable_enabled?
208
+
209
+ versions.select { |v| v.changed?(field) }.map do |version|
210
+ change = version.change_for(field)
211
+ {
212
+ before: change[:before],
213
+ after: change[:after],
214
+ at: version.created_at,
215
+ by: version.updated_by_id,
216
+ reason: version.updated_reason
217
+ }
218
+ end
219
+ end
220
+
221
+ # Get formatted audit trail
222
+ #
223
+ # @return [Array<Hash>] Full audit trail
224
+ def audit_trail
225
+ raise NotEnabledError unless self.class.traceable_enabled?
226
+
227
+ versions.map do |version|
228
+ {
229
+ event: version.event,
230
+ changes: version.object_changes || {},
231
+ at: version.created_at,
232
+ by: version.updated_by_id,
233
+ reason: version.updated_reason
234
+ }
235
+ end
236
+ end
237
+
238
+ # Reconstruct object state at a specific point in time
239
+ #
240
+ # @param timestamp [Time, Date] Point in time
241
+ # @return [self] Reconstructed object (not saved)
242
+ def as_of(timestamp)
243
+ raise NotEnabledError unless self.class.traceable_enabled?
244
+
245
+ # Get all versions up to timestamp, ordered from oldest to newest
246
+ relevant_versions = versions.where("created_at <= ?", timestamp).order(created_at: :asc)
247
+
248
+ # Start with a blank object
249
+ reconstructed = self.class.new
250
+
251
+ # Apply each version's "after" value in chronological order
252
+ relevant_versions.each do |version|
253
+ next unless version.object_changes
254
+
255
+ version.object_changes.each do |field, (_before_value, after_value)|
256
+ reconstructed.send("#{field}=", after_value) if reconstructed.respond_to?("#{field}=")
257
+ end
258
+ end
259
+
260
+ reconstructed.id = id
261
+ reconstructed.readonly!
262
+ reconstructed
263
+ end
264
+
265
+ # Rollback to a specific version
266
+ #
267
+ # @param version [BetterModel::Version, Integer] Version or version ID
268
+ # @param updated_by_id [Integer] User ID performing rollback
269
+ # @param updated_reason [String] Reason for rollback
270
+ # @return [self]
271
+ def rollback_to(version, updated_by_id: nil, updated_reason: nil)
272
+ raise NotEnabledError unless self.class.traceable_enabled?
273
+
274
+ begin
275
+ version = versions.find(version) if version.is_a?(Integer)
276
+ rescue ActiveRecord::RecordNotFound
277
+ raise ArgumentError, "Version not found"
278
+ end
279
+
280
+ raise ArgumentError, "Version not found" unless version
281
+ raise ArgumentError, "Version does not belong to this record" unless version.item == self
282
+
283
+ # Apply changes from version
284
+ if version.object_changes
285
+ version.object_changes.each do |field, (before_value, _after_value)|
286
+ send("#{field}=", before_value) if respond_to?("#{field}=")
287
+ end
288
+ end
289
+
290
+ # Save with tracking
291
+ self.updated_by_id = updated_by_id if respond_to?(:updated_by_id=)
292
+ self.updated_reason = updated_reason || "Rolled back to version #{version.id}" if respond_to?(:updated_reason=)
293
+
294
+ save!(validate: false)
295
+ self
296
+ end
297
+
298
+ # Override as_json to include audit trail
299
+ #
300
+ # @param options [Hash] Options
301
+ # @option options [Boolean] :include_audit_trail Include full audit trail
302
+ # @return [Hash]
303
+ def as_json(options = {})
304
+ result = super
305
+
306
+ if options[:include_audit_trail] && self.class.traceable_enabled?
307
+ result["audit_trail"] = audit_trail
308
+ end
309
+
310
+ result
311
+ end
312
+
313
+ private
314
+
315
+ # Create version on record creation
316
+ def create_version_on_create
317
+ return unless self.class.traceable_enabled?
318
+
319
+ create_version("created", tracked_changes)
320
+ end
321
+
322
+ # Create version on record update
323
+ def create_version_on_update
324
+ return unless self.class.traceable_enabled?
325
+
326
+ changes = tracked_changes
327
+ return if changes.empty?
328
+
329
+ create_version("updated", changes)
330
+ end
331
+
332
+ # Create version on record destruction
333
+ def create_version_on_destroy
334
+ return unless self.class.traceable_enabled?
335
+
336
+ create_version("destroyed", tracked_final_state)
337
+ end
338
+
339
+ # Get tracked changes (only for configured fields)
340
+ def tracked_changes
341
+ return {} if self.class.traceable_fields.empty?
342
+
343
+ if saved_changes.any?
344
+ # After save: use saved_changes
345
+ saved_changes.slice(*self.class.traceable_fields.map(&:to_s))
346
+ else
347
+ # Before save: use changes
348
+ changes.slice(*self.class.traceable_fields.map(&:to_s))
349
+ end
350
+ end
351
+
352
+ # Get final state for destroyed records
353
+ def tracked_final_state
354
+ self.class.traceable_fields.each_with_object({}) do |field, hash|
355
+ hash[field.to_s] = [send(field), nil]
356
+ end
357
+ end
358
+
359
+ # Create a version record
360
+ def create_version(event_type, changes_hash)
361
+ versions.create!(
362
+ event: event_type,
363
+ object_changes: changes_hash,
364
+ updated_by_id: try(:updated_by_id),
365
+ updated_reason: try(:updated_reason)
366
+ )
367
+ end
368
+ end
369
+
370
+ # Errori custom
371
+ class TraceableError < StandardError; end
372
+
373
+ class NotEnabledError < TraceableError
374
+ def initialize(msg = nil)
375
+ super(msg || "Traceable is not enabled. Add 'traceable do...end' to your model.")
376
+ end
377
+ end
378
+
379
+ # Configurator per traceable DSL
380
+ class TraceableConfigurator
381
+ attr_reader :fields, :table_name
382
+
383
+ def initialize(model_class)
384
+ @model_class = model_class
385
+ @fields = []
386
+ @table_name = nil
387
+ end
388
+
389
+ # Specify which fields to track
390
+ #
391
+ # @param field_names [Array<Symbol>] Field names to track
392
+ def track(*field_names)
393
+ @fields.concat(field_names)
394
+ end
395
+
396
+ # Specify custom table name for versions
397
+ #
398
+ # @param name [String, Symbol] Table name
399
+ def versions_table(name)
400
+ @table_name = name.to_s
401
+ end
402
+
403
+ def to_h
404
+ { fields: @fields, table_name: @table_name }
405
+ end
406
+ end
407
+
408
+ # Query builder for change-specific queries
409
+ class ChangeQuery
410
+ def initialize(model_class, field)
411
+ @model_class = model_class
412
+ @field = field.to_s
413
+ @from_value = nil
414
+ @to_value = nil
415
+ @table_name = model_class.traceable_table_name
416
+ end
417
+
418
+ def from(value)
419
+ @from_value = value
420
+ self
421
+ end
422
+
423
+ def to(value)
424
+ @to_value = value
425
+ execute_query
426
+ end
427
+
428
+ private
429
+
430
+ def execute_query
431
+ query = @model_class
432
+ .joins(:versions)
433
+ .where("#{@table_name}.object_changes->>'#{@field}' IS NOT NULL")
434
+
435
+ if @from_value
436
+ query = query.where("#{@table_name}.object_changes->>'#{@field}' LIKE ?", "%#{@from_value}%")
437
+ end
438
+
439
+ if @to_value
440
+ query = query.where("#{@table_name}.object_changes->>'#{@field}' LIKE ?", "%#{@to_value}%")
441
+ end
442
+
443
+ query.distinct
444
+ end
445
+ end
446
+ 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