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.
- checksums.yaml +4 -4
- data/README.md +427 -85
- data/lib/better_model/archivable.rb +10 -10
- data/lib/better_model/predicable.rb +39 -52
- data/lib/better_model/sortable.rb +4 -0
- 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 +354 -0
- data/lib/better_model/traceable.rb +498 -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,354 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Stateable - Declarative State Machine per modelli Rails
|
|
4
|
+
#
|
|
5
|
+
# Questo concern permette di definire state machines dichiarative con:
|
|
6
|
+
# - Stati espliciti con initial state
|
|
7
|
+
# - Transizioni con guards, validazioni e callbacks
|
|
8
|
+
# - Tracking storico transizioni
|
|
9
|
+
# - Integrazione con Statusable per guards
|
|
10
|
+
#
|
|
11
|
+
# APPROCCIO OPT-IN: La state machine non è attiva automaticamente.
|
|
12
|
+
# Devi chiamare esplicitamente `stateable do...end` nel tuo modello.
|
|
13
|
+
#
|
|
14
|
+
# REQUISITI DATABASE:
|
|
15
|
+
# - Colonna `state` (string) nel modello
|
|
16
|
+
# - Tabella per storico transizioni (default: `state_transitions`, configurabile)
|
|
17
|
+
#
|
|
18
|
+
# Esempio di utilizzo:
|
|
19
|
+
# class Order < ApplicationRecord
|
|
20
|
+
# include BetterModel
|
|
21
|
+
#
|
|
22
|
+
# # Optional: Statusable per status derivati
|
|
23
|
+
# is :payable, -> { confirmed? && !paid? }
|
|
24
|
+
#
|
|
25
|
+
# # Attiva stateable (opt-in)
|
|
26
|
+
# stateable do
|
|
27
|
+
# # Stati
|
|
28
|
+
# state :pending, initial: true
|
|
29
|
+
# state :confirmed
|
|
30
|
+
# state :paid
|
|
31
|
+
# state :cancelled
|
|
32
|
+
#
|
|
33
|
+
# # Transizioni
|
|
34
|
+
# transition :confirm, from: :pending, to: :confirmed do
|
|
35
|
+
# guard { items.any? }
|
|
36
|
+
# guard :customer_valid?
|
|
37
|
+
# guard if: :is_payable? # Statusable integration
|
|
38
|
+
#
|
|
39
|
+
# validate { errors.add(:base, "Stock unavailable") unless stock_available? }
|
|
40
|
+
#
|
|
41
|
+
# before { calculate_total }
|
|
42
|
+
# after { send_confirmation_email }
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# transition :pay, from: :confirmed, to: :paid do
|
|
46
|
+
# before { charge_payment }
|
|
47
|
+
# end
|
|
48
|
+
#
|
|
49
|
+
# transition :cancel, from: [:pending, :confirmed], to: :cancelled
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# # Optional: Custom table name for transitions
|
|
53
|
+
# transitions_table 'order_transitions'
|
|
54
|
+
# end
|
|
55
|
+
#
|
|
56
|
+
# Utilizzo:
|
|
57
|
+
# order.state # => "pending"
|
|
58
|
+
# order.pending? # => true
|
|
59
|
+
# order.can_confirm? # => true (controlla guards)
|
|
60
|
+
# order.confirm! # Esegue transizione
|
|
61
|
+
# order.state # => "confirmed"
|
|
62
|
+
#
|
|
63
|
+
# order.state_transitions # => Array di StateTransition records
|
|
64
|
+
# order.transition_history # => Array formattato di transizioni
|
|
65
|
+
#
|
|
66
|
+
# Table Naming Options:
|
|
67
|
+
# # Option 1: Default shared table (state_transitions)
|
|
68
|
+
# stateable do
|
|
69
|
+
# # Uses 'state_transitions' table by default
|
|
70
|
+
# end
|
|
71
|
+
#
|
|
72
|
+
# # Option 2: Custom table name
|
|
73
|
+
# stateable do
|
|
74
|
+
# transitions_table 'order_transitions'
|
|
75
|
+
# end
|
|
76
|
+
#
|
|
77
|
+
# # Option 3: Shared custom table
|
|
78
|
+
# class Order < ApplicationRecord
|
|
79
|
+
# stateable do
|
|
80
|
+
# transitions_table 'transitions'
|
|
81
|
+
# end
|
|
82
|
+
# end
|
|
83
|
+
# class Article < ApplicationRecord
|
|
84
|
+
# stateable do
|
|
85
|
+
# transitions_table 'transitions' # Same table
|
|
86
|
+
# end
|
|
87
|
+
# end
|
|
88
|
+
#
|
|
89
|
+
module BetterModel
|
|
90
|
+
module Stateable
|
|
91
|
+
extend ActiveSupport::Concern
|
|
92
|
+
|
|
93
|
+
# Thread-safe mutex for dynamic class creation
|
|
94
|
+
CLASS_CREATION_MUTEX = Mutex.new
|
|
95
|
+
|
|
96
|
+
included do
|
|
97
|
+
# Validazione ActiveRecord
|
|
98
|
+
unless ancestors.include?(ActiveRecord::Base)
|
|
99
|
+
raise ArgumentError, "BetterModel::Stateable can only be included in ActiveRecord models"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Configurazione stateable (opt-in)
|
|
103
|
+
class_attribute :stateable_enabled, default: false
|
|
104
|
+
class_attribute :stateable_config, default: {}.freeze
|
|
105
|
+
class_attribute :stateable_states, default: [].freeze
|
|
106
|
+
class_attribute :stateable_transitions, default: {}.freeze
|
|
107
|
+
class_attribute :stateable_initial_state, default: nil
|
|
108
|
+
class_attribute :stateable_table_name, default: nil
|
|
109
|
+
class_attribute :_stateable_setup_done, default: false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
class_methods do
|
|
113
|
+
# DSL per attivare e configurare stateable (OPT-IN)
|
|
114
|
+
#
|
|
115
|
+
# @example Attivazione base
|
|
116
|
+
# stateable do
|
|
117
|
+
# state :draft, initial: true
|
|
118
|
+
# state :published
|
|
119
|
+
# transition :publish, from: :draft, to: :published
|
|
120
|
+
# end
|
|
121
|
+
#
|
|
122
|
+
# @example Con guards e callbacks
|
|
123
|
+
# stateable do
|
|
124
|
+
# state :pending, initial: true
|
|
125
|
+
# state :confirmed
|
|
126
|
+
#
|
|
127
|
+
# transition :confirm, from: :pending, to: :confirmed do
|
|
128
|
+
# guard { valid? }
|
|
129
|
+
# before { prepare_confirmation }
|
|
130
|
+
# after { send_notification }
|
|
131
|
+
# end
|
|
132
|
+
# end
|
|
133
|
+
#
|
|
134
|
+
def stateable(&block)
|
|
135
|
+
# Attiva stateable
|
|
136
|
+
self.stateable_enabled = true
|
|
137
|
+
|
|
138
|
+
# Configura se passato un blocco
|
|
139
|
+
if block_given?
|
|
140
|
+
configurator = Configurator.new(self)
|
|
141
|
+
configurator.instance_eval(&block)
|
|
142
|
+
|
|
143
|
+
self.stateable_config = configurator.to_h.freeze
|
|
144
|
+
self.stateable_states = configurator.states.freeze
|
|
145
|
+
self.stateable_transitions = configurator.transitions.freeze
|
|
146
|
+
self.stateable_initial_state = configurator.initial_state
|
|
147
|
+
self.stateable_table_name = configurator.table_name
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Set default table name if not configured
|
|
151
|
+
self.stateable_table_name ||= "state_transitions"
|
|
152
|
+
|
|
153
|
+
# Setup methods only once
|
|
154
|
+
return if self._stateable_setup_done
|
|
155
|
+
|
|
156
|
+
self._stateable_setup_done = true
|
|
157
|
+
|
|
158
|
+
# Setup association con StateTransition
|
|
159
|
+
setup_state_transitions_association
|
|
160
|
+
|
|
161
|
+
# Setup dynamic methods
|
|
162
|
+
setup_dynamic_methods
|
|
163
|
+
|
|
164
|
+
# Setup validations
|
|
165
|
+
setup_state_validation
|
|
166
|
+
|
|
167
|
+
# Setup callbacks
|
|
168
|
+
setup_initial_state_callback
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Verifica se stateable è attivo
|
|
172
|
+
#
|
|
173
|
+
# @return [Boolean]
|
|
174
|
+
def stateable_enabled?
|
|
175
|
+
stateable_enabled == true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
# Setup association con StateTransition model
|
|
181
|
+
def setup_state_transitions_association
|
|
182
|
+
# Create or retrieve a StateTransition class for the given table name
|
|
183
|
+
transition_class = create_state_transition_class_for_table(stateable_table_name)
|
|
184
|
+
|
|
185
|
+
has_many :state_transitions,
|
|
186
|
+
-> { order(created_at: :desc) },
|
|
187
|
+
as: :transitionable,
|
|
188
|
+
class_name: transition_class.name,
|
|
189
|
+
dependent: :destroy
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Create or retrieve a StateTransition class for the given table name
|
|
193
|
+
#
|
|
194
|
+
# Thread-safe implementation using mutex to prevent race conditions
|
|
195
|
+
# when multiple threads try to create the same class simultaneously.
|
|
196
|
+
#
|
|
197
|
+
# @param table_name [String] Table name for state transitions
|
|
198
|
+
# @return [Class] StateTransition class
|
|
199
|
+
#
|
|
200
|
+
def create_state_transition_class_for_table(table_name)
|
|
201
|
+
# Create a unique class name based on table name
|
|
202
|
+
class_name = "#{table_name.camelize.singularize}"
|
|
203
|
+
|
|
204
|
+
# Fast path: check if class already exists (no lock needed)
|
|
205
|
+
if BetterModel.const_defined?(class_name, false)
|
|
206
|
+
return BetterModel.const_get(class_name)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Slow path: acquire lock and create class
|
|
210
|
+
CLASS_CREATION_MUTEX.synchronize do
|
|
211
|
+
# Double-check after acquiring lock (another thread may have created it)
|
|
212
|
+
if BetterModel.const_defined?(class_name, false)
|
|
213
|
+
return BetterModel.const_get(class_name)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Create new StateTransition class dynamically
|
|
217
|
+
transition_class = Class.new(BetterModel::StateTransition) do
|
|
218
|
+
self.table_name = table_name
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Register the class in BetterModel namespace
|
|
222
|
+
BetterModel.const_set(class_name, transition_class)
|
|
223
|
+
transition_class
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Setup dynamic methods per stati e transizioni
|
|
228
|
+
def setup_dynamic_methods
|
|
229
|
+
# Metodi per ogni stato: pending?, confirmed?, etc.
|
|
230
|
+
stateable_states.each do |state_name|
|
|
231
|
+
define_method "#{state_name}?" do
|
|
232
|
+
state.to_s == state_name.to_s
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Metodi per ogni transizione: confirm!, can_confirm?, etc.
|
|
237
|
+
stateable_transitions.each do |event_name, transition_config|
|
|
238
|
+
# event! - esegue transizione (raise se fallisce)
|
|
239
|
+
define_method "#{event_name}!" do |**metadata|
|
|
240
|
+
transition_to!(event_name, **metadata)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# can_event? - controlla se transizione è possibile
|
|
244
|
+
define_method "can_#{event_name}?" do
|
|
245
|
+
can_transition_to?(event_name)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Setup validazione dello stato
|
|
251
|
+
def setup_state_validation
|
|
252
|
+
validates :state, presence: true, inclusion: { in: ->(_) { stateable_states.map(&:to_s) } }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Setup callback per impostare initial state
|
|
256
|
+
def setup_initial_state_callback
|
|
257
|
+
before_validation :set_initial_state, on: :create
|
|
258
|
+
|
|
259
|
+
define_method :set_initial_state do
|
|
260
|
+
return if state.present?
|
|
261
|
+
self.state = self.class.stateable_initial_state.to_s if self.class.stateable_initial_state
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Metodi di istanza
|
|
267
|
+
|
|
268
|
+
# Esegue una transizione di stato
|
|
269
|
+
#
|
|
270
|
+
# @param event [Symbol] Nome della transizione
|
|
271
|
+
# @param metadata [Hash] Metadata opzionale da salvare nella StateTransition
|
|
272
|
+
# @raise [InvalidTransitionError] Se la transizione non è valida
|
|
273
|
+
# @raise [GuardFailedError] Se un guard fallisce
|
|
274
|
+
# @raise [ValidationFailedError] Se una validazione fallisce
|
|
275
|
+
# @return [Boolean] true se la transizione ha successo
|
|
276
|
+
#
|
|
277
|
+
def transition_to!(event, **metadata)
|
|
278
|
+
raise NotEnabledError unless self.class.stateable_enabled?
|
|
279
|
+
|
|
280
|
+
transition_config = self.class.stateable_transitions[event.to_sym]
|
|
281
|
+
raise ArgumentError, "Unknown transition: #{event}" unless transition_config
|
|
282
|
+
|
|
283
|
+
current_state = state.to_sym
|
|
284
|
+
|
|
285
|
+
# Verifica che from_state sia valido
|
|
286
|
+
from_states = Array(transition_config[:from])
|
|
287
|
+
unless from_states.include?(current_state)
|
|
288
|
+
raise InvalidTransitionError.new(event, current_state, transition_config[:to])
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Esegui la transizione usando Transition executor
|
|
292
|
+
transition = Transition.new(self, event, transition_config, metadata)
|
|
293
|
+
transition.execute!
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Verifica se una transizione è possibile
|
|
297
|
+
#
|
|
298
|
+
# @param event [Symbol] Nome della transizione
|
|
299
|
+
# @return [Boolean] true se la transizione è possibile
|
|
300
|
+
#
|
|
301
|
+
def can_transition_to?(event)
|
|
302
|
+
return false unless self.class.stateable_enabled?
|
|
303
|
+
|
|
304
|
+
transition_config = self.class.stateable_transitions[event.to_sym]
|
|
305
|
+
return false unless transition_config
|
|
306
|
+
|
|
307
|
+
current_state = state.to_sym
|
|
308
|
+
from_states = Array(transition_config[:from])
|
|
309
|
+
return false unless from_states.include?(current_state)
|
|
310
|
+
|
|
311
|
+
# Verifica guards
|
|
312
|
+
guards = transition_config[:guards] || []
|
|
313
|
+
guards.all? do |guard|
|
|
314
|
+
Guard.new(self, guard).evaluate
|
|
315
|
+
end
|
|
316
|
+
rescue StandardError
|
|
317
|
+
false
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Ottieni lo storico delle transizioni formattato
|
|
321
|
+
#
|
|
322
|
+
# @return [Array<Hash>] Array di transizioni con :event, :from, :to, :at, :metadata
|
|
323
|
+
#
|
|
324
|
+
def transition_history
|
|
325
|
+
raise NotEnabledError unless self.class.stateable_enabled?
|
|
326
|
+
|
|
327
|
+
state_transitions.map do |transition|
|
|
328
|
+
{
|
|
329
|
+
event: transition.event,
|
|
330
|
+
from: transition.from_state,
|
|
331
|
+
to: transition.to_state,
|
|
332
|
+
at: transition.created_at,
|
|
333
|
+
metadata: transition.metadata
|
|
334
|
+
}
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Override as_json per includere transition history
|
|
339
|
+
#
|
|
340
|
+
# @param options [Hash] Options
|
|
341
|
+
# @option options [Boolean] :include_transition_history Include full history
|
|
342
|
+
# @return [Hash]
|
|
343
|
+
#
|
|
344
|
+
def as_json(options = {})
|
|
345
|
+
result = super
|
|
346
|
+
|
|
347
|
+
if options[:include_transition_history] && self.class.stateable_enabled?
|
|
348
|
+
result["transition_history"] = transition_history
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
result
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|