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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterModel
4
+ module Stateable
5
+ # Guard evaluator per Stateable
6
+ #
7
+ # Valuta le guard conditions per determinare se una transizione è permessa.
8
+ # Supporta tre tipi di guards:
9
+ # - Block: lambda/proc valutato nel contesto dell'istanza
10
+ # - Method: metodo chiamato sull'istanza
11
+ # - Predicate: integrazione con Statusable (is_ready?, etc.)
12
+ #
13
+ class Guard
14
+ def initialize(instance, guard_config)
15
+ @instance = instance
16
+ @guard_config = guard_config
17
+ end
18
+
19
+ # Valuta il guard
20
+ #
21
+ # @return [Boolean] true se il guard passa
22
+ # @raise [GuardFailedError] Se il guard fallisce (opzionale, dipende dal contesto)
23
+ #
24
+ def evaluate
25
+ case @guard_config[:type]
26
+ when :block
27
+ evaluate_block
28
+ when :method
29
+ evaluate_method
30
+ when :predicate
31
+ evaluate_predicate
32
+ else
33
+ raise StateableError, "Unknown guard type: #{@guard_config[:type]}"
34
+ end
35
+ end
36
+
37
+ # Descrizione del guard per messaggi di errore
38
+ #
39
+ # @return [String] Descrizione human-readable
40
+ #
41
+ def description
42
+ case @guard_config[:type]
43
+ when :block
44
+ "block guard"
45
+ when :method
46
+ "method guard: #{@guard_config[:method]}"
47
+ when :predicate
48
+ "predicate guard: #{@guard_config[:predicate]}"
49
+ else
50
+ "unknown guard"
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ # Valuta un guard block
57
+ def evaluate_block
58
+ block = @guard_config[:block]
59
+ @instance.instance_exec(&block)
60
+ end
61
+
62
+ # Valuta un guard method
63
+ def evaluate_method
64
+ method_name = @guard_config[:method]
65
+
66
+ unless @instance.respond_to?(method_name, true)
67
+ raise NoMethodError, "Guard method '#{method_name}' not found in #{@instance.class.name}. " \
68
+ "Define it in your model: def #{method_name}; ...; end"
69
+ end
70
+
71
+ @instance.send(method_name)
72
+ end
73
+
74
+ # Valuta un guard predicate (integrazione Statusable)
75
+ def evaluate_predicate
76
+ predicate_name = @guard_config[:predicate]
77
+
78
+ unless @instance.respond_to?(predicate_name)
79
+ raise NoMethodError, "Guard predicate '#{predicate_name}' not found in #{@instance.class.name}. " \
80
+ "Make sure Statusable is enabled and the predicate is defined: is :ready, -> { ... }"
81
+ end
82
+
83
+ @instance.send(predicate_name)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterModel
4
+ module Stateable
5
+ # Transition executor per Stateable
6
+ #
7
+ # Gestisce l'esecuzione di una transizione di stato, includendo:
8
+ # - Valutazione guards
9
+ # - Esecuzione validazioni
10
+ # - Esecuzione callbacks (before/after/around)
11
+ # - Aggiornamento stato nel database
12
+ # - Creazione record StateTransition per storico
13
+ #
14
+ class Transition
15
+ def initialize(instance, event, config, metadata = {})
16
+ @instance = instance
17
+ @event = event
18
+ @config = config
19
+ @metadata = metadata
20
+ @from_state = instance.state.to_sym
21
+ @to_state = config[:to]
22
+ end
23
+
24
+ # Esegue la transizione
25
+ #
26
+ # @raise [GuardFailedError] Se un guard fallisce
27
+ # @raise [ValidationFailedError] Se una validazione fallisce
28
+ # @raise [ActiveRecord::RecordInvalid] Se il save! fallisce
29
+ # @return [Boolean] true se la transizione ha successo
30
+ #
31
+ def execute!
32
+ # 1. Valuta guards
33
+ evaluate_guards!
34
+
35
+ # 2. Esegui validazioni
36
+ execute_validations!
37
+
38
+ # 3. Wrap in transaction
39
+ @instance.class.transaction do
40
+ # 4. Esegui callbacks around (se presenti)
41
+ if @config[:around_callbacks].any?
42
+ execute_around_callbacks do
43
+ perform_transition!
44
+ end
45
+ else
46
+ perform_transition!
47
+ end
48
+ end
49
+
50
+ true
51
+ end
52
+
53
+ private
54
+
55
+ # Valuta tutti i guards
56
+ def evaluate_guards!
57
+ guards = @config[:guards] || []
58
+
59
+ guards.each do |guard_config|
60
+ guard = Guard.new(@instance, guard_config)
61
+
62
+ unless guard.evaluate
63
+ raise GuardFailedError.new(@event, guard.description)
64
+ end
65
+ end
66
+ end
67
+
68
+ # Esegue tutte le validazioni
69
+ def execute_validations!
70
+ validations = @config[:validations] || []
71
+ return if validations.empty?
72
+
73
+ # Clear existing errors per questa transizione
74
+ @instance.errors.clear
75
+
76
+ validations.each do |validation_block|
77
+ @instance.instance_exec(&validation_block)
78
+ end
79
+
80
+ if @instance.errors.any?
81
+ raise ValidationFailedError.new(@event, @instance.errors)
82
+ end
83
+ end
84
+
85
+ # Esegue i callback around
86
+ def execute_around_callbacks(&block)
87
+ around_callbacks = @config[:around_callbacks] || []
88
+
89
+ if around_callbacks.empty?
90
+ block.call
91
+ return
92
+ end
93
+
94
+ # Nested around callbacks
95
+ chain = around_callbacks.reverse.reduce(block) do |inner, around_callback|
96
+ proc { @instance.instance_exec(inner, &around_callback) }
97
+ end
98
+
99
+ chain.call
100
+ end
101
+
102
+ # Esegue la transizione effettiva
103
+ def perform_transition!
104
+ # 1. Esegui before callbacks
105
+ execute_callbacks(@config[:before_callbacks] || [])
106
+
107
+ # 2. Aggiorna stato
108
+ @instance.state = @to_state.to_s
109
+
110
+ # 3. Salva il record (valida il modello)
111
+ @instance.save!
112
+
113
+ # 4. Crea record StateTransition
114
+ create_state_transition_record
115
+
116
+ # 5. Esegui after callbacks
117
+ execute_callbacks(@config[:after_callbacks] || [])
118
+ end
119
+
120
+ # Esegue una lista di callbacks
121
+ def execute_callbacks(callbacks)
122
+ callbacks.each do |callback_config|
123
+ case callback_config[:type]
124
+ when :block
125
+ @instance.instance_exec(&callback_config[:block])
126
+ when :method
127
+ @instance.send(callback_config[:method])
128
+ end
129
+ end
130
+ end
131
+
132
+ # Crea il record StateTransition per lo storico
133
+ def create_state_transition_record
134
+ @instance.state_transitions.create!(
135
+ event: @event.to_s,
136
+ from_state: @from_state.to_s,
137
+ to_state: @to_state.to_s,
138
+ metadata: @metadata
139
+ )
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,340 @@
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
+ included do
94
+ # Validazione ActiveRecord
95
+ unless ancestors.include?(ActiveRecord::Base)
96
+ raise ArgumentError, "BetterModel::Stateable can only be included in ActiveRecord models"
97
+ end
98
+
99
+ # Configurazione stateable (opt-in)
100
+ class_attribute :stateable_enabled, default: false
101
+ class_attribute :stateable_config, default: {}.freeze
102
+ class_attribute :stateable_states, default: [].freeze
103
+ class_attribute :stateable_transitions, default: {}.freeze
104
+ class_attribute :stateable_initial_state, default: nil
105
+ class_attribute :stateable_table_name, default: nil
106
+ class_attribute :_stateable_setup_done, default: false
107
+ end
108
+
109
+ class_methods do
110
+ # DSL per attivare e configurare stateable (OPT-IN)
111
+ #
112
+ # @example Attivazione base
113
+ # stateable do
114
+ # state :draft, initial: true
115
+ # state :published
116
+ # transition :publish, from: :draft, to: :published
117
+ # end
118
+ #
119
+ # @example Con guards e callbacks
120
+ # stateable do
121
+ # state :pending, initial: true
122
+ # state :confirmed
123
+ #
124
+ # transition :confirm, from: :pending, to: :confirmed do
125
+ # guard { valid? }
126
+ # before { prepare_confirmation }
127
+ # after { send_notification }
128
+ # end
129
+ # end
130
+ #
131
+ def stateable(&block)
132
+ # Attiva stateable
133
+ self.stateable_enabled = true
134
+
135
+ # Configura se passato un blocco
136
+ if block_given?
137
+ configurator = Configurator.new(self)
138
+ configurator.instance_eval(&block)
139
+
140
+ self.stateable_config = configurator.to_h.freeze
141
+ self.stateable_states = configurator.states.freeze
142
+ self.stateable_transitions = configurator.transitions.freeze
143
+ self.stateable_initial_state = configurator.initial_state
144
+ self.stateable_table_name = configurator.table_name
145
+ end
146
+
147
+ # Set default table name if not configured
148
+ self.stateable_table_name ||= "state_transitions"
149
+
150
+ # Setup methods only once
151
+ return if self._stateable_setup_done
152
+
153
+ self._stateable_setup_done = true
154
+
155
+ # Setup association con StateTransition
156
+ setup_state_transitions_association
157
+
158
+ # Setup dynamic methods
159
+ setup_dynamic_methods
160
+
161
+ # Setup validations
162
+ setup_state_validation
163
+
164
+ # Setup callbacks
165
+ setup_initial_state_callback
166
+ end
167
+
168
+ # Verifica se stateable è attivo
169
+ #
170
+ # @return [Boolean]
171
+ def stateable_enabled?
172
+ stateable_enabled == true
173
+ end
174
+
175
+ private
176
+
177
+ # Setup association con StateTransition model
178
+ def setup_state_transitions_association
179
+ # Create or retrieve a StateTransition class for the given table name
180
+ transition_class = create_state_transition_class_for_table(stateable_table_name)
181
+
182
+ has_many :state_transitions,
183
+ -> { order(created_at: :desc) },
184
+ as: :transitionable,
185
+ class_name: transition_class.name,
186
+ dependent: :destroy
187
+ end
188
+
189
+ # Create or retrieve a StateTransition class for the given table name
190
+ #
191
+ # @param table_name [String] Table name for state transitions
192
+ # @return [Class] StateTransition class
193
+ #
194
+ def create_state_transition_class_for_table(table_name)
195
+ # Create a unique class name based on table name
196
+ class_name = "#{table_name.camelize.singularize}"
197
+
198
+ # Check if class already exists in BetterModel namespace
199
+ if BetterModel.const_defined?(class_name, false)
200
+ return BetterModel.const_get(class_name)
201
+ end
202
+
203
+ # Create new StateTransition class dynamically
204
+ transition_class = Class.new(BetterModel::StateTransition) do
205
+ self.table_name = table_name
206
+ end
207
+
208
+ # Register the class in BetterModel namespace
209
+ BetterModel.const_set(class_name, transition_class)
210
+ transition_class
211
+ end
212
+
213
+ # Setup dynamic methods per stati e transizioni
214
+ def setup_dynamic_methods
215
+ # Metodi per ogni stato: pending?, confirmed?, etc.
216
+ stateable_states.each do |state_name|
217
+ define_method "#{state_name}?" do
218
+ state.to_s == state_name.to_s
219
+ end
220
+ end
221
+
222
+ # Metodi per ogni transizione: confirm!, can_confirm?, etc.
223
+ stateable_transitions.each do |event_name, transition_config|
224
+ # event! - esegue transizione (raise se fallisce)
225
+ define_method "#{event_name}!" do |**metadata|
226
+ transition_to!(event_name, **metadata)
227
+ end
228
+
229
+ # can_event? - controlla se transizione è possibile
230
+ define_method "can_#{event_name}?" do
231
+ can_transition_to?(event_name)
232
+ end
233
+ end
234
+ end
235
+
236
+ # Setup validazione dello stato
237
+ def setup_state_validation
238
+ validates :state, presence: true, inclusion: { in: ->(_) { stateable_states.map(&:to_s) } }
239
+ end
240
+
241
+ # Setup callback per impostare initial state
242
+ def setup_initial_state_callback
243
+ before_validation :set_initial_state, on: :create
244
+
245
+ define_method :set_initial_state do
246
+ return if state.present?
247
+ self.state = self.class.stateable_initial_state.to_s if self.class.stateable_initial_state
248
+ end
249
+ end
250
+ end
251
+
252
+ # Metodi di istanza
253
+
254
+ # Esegue una transizione di stato
255
+ #
256
+ # @param event [Symbol] Nome della transizione
257
+ # @param metadata [Hash] Metadata opzionale da salvare nella StateTransition
258
+ # @raise [InvalidTransitionError] Se la transizione non è valida
259
+ # @raise [GuardFailedError] Se un guard fallisce
260
+ # @raise [ValidationFailedError] Se una validazione fallisce
261
+ # @return [Boolean] true se la transizione ha successo
262
+ #
263
+ def transition_to!(event, **metadata)
264
+ raise NotEnabledError unless self.class.stateable_enabled?
265
+
266
+ transition_config = self.class.stateable_transitions[event.to_sym]
267
+ raise ArgumentError, "Unknown transition: #{event}" unless transition_config
268
+
269
+ current_state = state.to_sym
270
+
271
+ # Verifica che from_state sia valido
272
+ from_states = Array(transition_config[:from])
273
+ unless from_states.include?(current_state)
274
+ raise InvalidTransitionError.new(event, current_state, transition_config[:to])
275
+ end
276
+
277
+ # Esegui la transizione usando Transition executor
278
+ transition = Transition.new(self, event, transition_config, metadata)
279
+ transition.execute!
280
+ end
281
+
282
+ # Verifica se una transizione è possibile
283
+ #
284
+ # @param event [Symbol] Nome della transizione
285
+ # @return [Boolean] true se la transizione è possibile
286
+ #
287
+ def can_transition_to?(event)
288
+ return false unless self.class.stateable_enabled?
289
+
290
+ transition_config = self.class.stateable_transitions[event.to_sym]
291
+ return false unless transition_config
292
+
293
+ current_state = state.to_sym
294
+ from_states = Array(transition_config[:from])
295
+ return false unless from_states.include?(current_state)
296
+
297
+ # Verifica guards
298
+ guards = transition_config[:guards] || []
299
+ guards.all? do |guard|
300
+ Guard.new(self, guard).evaluate
301
+ end
302
+ rescue StandardError
303
+ false
304
+ end
305
+
306
+ # Ottieni lo storico delle transizioni formattato
307
+ #
308
+ # @return [Array<Hash>] Array di transizioni con :event, :from, :to, :at, :metadata
309
+ #
310
+ def transition_history
311
+ raise NotEnabledError unless self.class.stateable_enabled?
312
+
313
+ state_transitions.map do |transition|
314
+ {
315
+ event: transition.event,
316
+ from: transition.from_state,
317
+ to: transition.to_state,
318
+ at: transition.created_at,
319
+ metadata: transition.metadata
320
+ }
321
+ end
322
+ end
323
+
324
+ # Override as_json per includere transition history
325
+ #
326
+ # @param options [Hash] Options
327
+ # @option options [Boolean] :include_transition_history Include full history
328
+ # @return [Hash]
329
+ #
330
+ def as_json(options = {})
331
+ result = super
332
+
333
+ if options[:include_transition_history] && self.class.stateable_enabled?
334
+ result["transition_history"] = transition_history
335
+ end
336
+
337
+ result
338
+ end
339
+ end
340
+ end