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.
- checksums.yaml +4 -4
- data/README.md +317 -85
- data/lib/better_model/archivable.rb +10 -10
- data/lib/better_model/predicable.rb +21 -44
- data/lib/better_model/state_transition.rb +106 -0
- data/lib/better_model/stateable/configurator.rb +300 -0
- data/lib/better_model/stateable/errors.rb +45 -0
- data/lib/better_model/stateable/guard.rb +87 -0
- data/lib/better_model/stateable/transition.rb +143 -0
- data/lib/better_model/stateable.rb +340 -0
- data/lib/better_model/traceable.rb +446 -0
- data/lib/better_model/validatable/business_rule_validator.rb +47 -0
- data/lib/better_model/validatable/configurator.rb +245 -0
- data/lib/better_model/validatable/order_validator.rb +77 -0
- data/lib/better_model/validatable.rb +270 -0
- data/lib/better_model/version.rb +1 -1
- data/lib/better_model/version_record.rb +63 -0
- data/lib/better_model.rb +15 -2
- data/lib/generators/better_model/stateable/install_generator.rb +37 -0
- data/lib/generators/better_model/stateable/stateable_generator.rb +50 -0
- data/lib/generators/better_model/stateable/templates/README +38 -0
- data/lib/generators/better_model/stateable/templates/install_migration.rb.tt +20 -0
- data/lib/generators/better_model/stateable/templates/migration.rb.tt +9 -0
- data/lib/generators/better_model/traceable/templates/create_table_migration.rb.tt +28 -0
- data/lib/generators/better_model/traceable/traceable_generator.rb +77 -0
- metadata +22 -3
|
@@ -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
|