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,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