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,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterModel
4
+ module Stateable
5
+ # Configurator per il DSL di Stateable
6
+ #
7
+ # Questo configurator permette di definire state machines in modo dichiarativo
8
+ # all'interno del blocco `stateable do...end`.
9
+ #
10
+ # Esempio:
11
+ # stateable do
12
+ # # Definisci stati
13
+ # state :pending, initial: true
14
+ # state :confirmed
15
+ # state :paid
16
+ #
17
+ # # Definisci transizioni
18
+ # transition :confirm, from: :pending, to: :confirmed do
19
+ # guard { items.any? }
20
+ # guard :customer_valid?
21
+ # guard if: :is_ready?
22
+ #
23
+ # validate { errors.add(:base, "Invalid") unless valid_for_confirmation? }
24
+ #
25
+ # before { prepare }
26
+ # after { notify }
27
+ # end
28
+ # end
29
+ #
30
+ class Configurator
31
+ attr_reader :states, :transitions, :initial_state, :table_name
32
+
33
+ def initialize(model_class)
34
+ @model_class = model_class
35
+ @states = []
36
+ @transitions = {}
37
+ @initial_state = nil
38
+ @table_name = nil
39
+ @current_transition = nil
40
+ end
41
+
42
+ # Definisce uno stato
43
+ #
44
+ # @param name [Symbol] Nome dello stato
45
+ # @param initial [Boolean] Se questo è lo stato iniziale
46
+ #
47
+ # @example
48
+ # state :draft, initial: true
49
+ # state :published
50
+ # state :archived
51
+ #
52
+ def state(name, initial: false)
53
+ raise ArgumentError, "State name must be a symbol" unless name.is_a?(Symbol)
54
+ raise ArgumentError, "State #{name} already defined" if @states.include?(name)
55
+
56
+ @states << name
57
+
58
+ if initial
59
+ raise ArgumentError, "Initial state already defined as #{@initial_state}" if @initial_state
60
+ @initial_state = name
61
+ end
62
+ end
63
+
64
+ # Definisce una transizione
65
+ #
66
+ # @param event [Symbol] Nome dell'evento/transizione
67
+ # @param from [Symbol, Array<Symbol>] Stato/i di partenza
68
+ # @param to [Symbol] Stato di arrivo
69
+ # @yield Blocco per configurare guards, validations, callbacks
70
+ #
71
+ # @example Transizione semplice
72
+ # transition :publish, from: :draft, to: :published
73
+ #
74
+ # @example Con guards e callbacks
75
+ # transition :confirm, from: :pending, to: :confirmed do
76
+ # guard { valid? }
77
+ # guard :ready_to_confirm?
78
+ # before { prepare_confirmation }
79
+ # after { send_email }
80
+ # end
81
+ #
82
+ # @example Da multipli stati
83
+ # transition :cancel, from: [:pending, :confirmed, :paid], to: :cancelled
84
+ #
85
+ def transition(event, from:, to:, &block)
86
+ raise ArgumentError, "Event name must be a symbol" unless event.is_a?(Symbol)
87
+ raise ArgumentError, "Transition #{event} already defined" if @transitions.key?(event)
88
+
89
+ # Normalizza from in array
90
+ from_states = Array(from)
91
+
92
+ # Verifica che gli stati esistano
93
+ from_states.each do |state_name|
94
+ unless @states.include?(state_name)
95
+ raise ArgumentError, "Unknown state in from: #{state_name}. Define it with 'state :#{state_name}' first."
96
+ end
97
+ end
98
+
99
+ unless @states.include?(to)
100
+ raise ArgumentError, "Unknown state in to: #{to}. Define it with 'state :#{to}' first."
101
+ end
102
+
103
+ # Inizializza configurazione transizione
104
+ @transitions[event] = {
105
+ from: from_states,
106
+ to: to,
107
+ guards: [],
108
+ validations: [],
109
+ before_callbacks: [],
110
+ after_callbacks: [],
111
+ around_callbacks: []
112
+ }
113
+
114
+ # Se c'è un blocco, configuralo
115
+ if block_given?
116
+ @current_transition = @transitions[event]
117
+ instance_eval(&block)
118
+ @current_transition = nil
119
+ end
120
+ end
121
+
122
+ # Definisce un guard per la transizione corrente
123
+ #
124
+ # I guards sono precondizioni che devono essere vere per permettere la transizione.
125
+ #
126
+ # @overload guard(&block)
127
+ # Guard con lambda/proc
128
+ # @yield Blocco da valutare nel contesto dell'istanza
129
+ # @example
130
+ # guard { items.any? && customer.present? }
131
+ #
132
+ # @overload guard(method_name)
133
+ # Guard con metodo
134
+ # @param method_name [Symbol] Nome del metodo da chiamare
135
+ # @example
136
+ # guard :customer_valid?
137
+ #
138
+ # @overload guard(if: predicate)
139
+ # Guard con Statusable predicate
140
+ # @param if [Symbol] Nome del predicate (integrazione Statusable)
141
+ # @example
142
+ # guard if: :is_ready_for_publishing?
143
+ #
144
+ def guard(method_name = nil, if: nil, &block)
145
+ raise StateableError, "guard can only be called inside a transition block" unless @current_transition
146
+
147
+ if block_given?
148
+ @current_transition[:guards] << { type: :block, block: block }
149
+ elsif method_name
150
+ @current_transition[:guards] << { type: :method, method: method_name }
151
+ elsif binding.local_variable_get(:if)
152
+ @current_transition[:guards] << { type: :predicate, predicate: binding.local_variable_get(:if) }
153
+ else
154
+ raise ArgumentError, "guard requires either a block, method name, or if: option"
155
+ end
156
+ end
157
+
158
+ # Definisce una validazione per la transizione corrente
159
+ #
160
+ # Le validazioni sono eseguite dopo i guards e prima dei callbacks.
161
+ # Devono aggiungere errori all'oggetto errors se la validazione fallisce.
162
+ #
163
+ # @yield Blocco da valutare nel contesto dell'istanza
164
+ #
165
+ # @example
166
+ # validate do
167
+ # errors.add(:base, "Stock unavailable") unless stock_available?
168
+ # errors.add(:payment, "required") if payment_method.blank?
169
+ # end
170
+ #
171
+ def validate(&block)
172
+ raise StateableError, "validate can only be called inside a transition block" unless @current_transition
173
+ raise ArgumentError, "validate requires a block" unless block_given?
174
+
175
+ @current_transition[:validations] << block
176
+ end
177
+
178
+ # Definisce un callback before per la transizione corrente
179
+ #
180
+ # I before callbacks sono eseguiti prima della transizione di stato.
181
+ #
182
+ # @overload before(&block)
183
+ # Before callback con lambda/proc
184
+ # @yield Blocco da eseguire
185
+ # @example
186
+ # before { calculate_total }
187
+ #
188
+ # @overload before(method_name)
189
+ # Before callback con metodo
190
+ # @param method_name [Symbol] Nome del metodo da chiamare
191
+ # @example
192
+ # before :calculate_total
193
+ #
194
+ def before(method_name = nil, &block)
195
+ raise StateableError, "before can only be called inside a transition block" unless @current_transition
196
+
197
+ if block_given?
198
+ @current_transition[:before_callbacks] << { type: :block, block: block }
199
+ elsif method_name
200
+ @current_transition[:before_callbacks] << { type: :method, method: method_name }
201
+ else
202
+ raise ArgumentError, "before requires either a block or method name"
203
+ end
204
+ end
205
+
206
+ # Definisce un callback after per la transizione corrente
207
+ #
208
+ # Gli after callbacks sono eseguiti dopo la transizione di stato.
209
+ #
210
+ # @overload after(&block)
211
+ # After callback con lambda/proc
212
+ # @yield Blocco da eseguire
213
+ # @example
214
+ # after { send_notification }
215
+ #
216
+ # @overload after(method_name)
217
+ # After callback con metodo
218
+ # @param method_name [Symbol] Nome del metodo da chiamare
219
+ # @example
220
+ # after :send_notification
221
+ #
222
+ def after(method_name = nil, &block)
223
+ raise StateableError, "after can only be called inside a transition block" unless @current_transition
224
+
225
+ if block_given?
226
+ @current_transition[:after_callbacks] << { type: :block, block: block }
227
+ elsif method_name
228
+ @current_transition[:after_callbacks] << { type: :method, method: method_name }
229
+ else
230
+ raise ArgumentError, "after requires either a block or method name"
231
+ end
232
+ end
233
+
234
+ # Definisce un callback around per la transizione corrente
235
+ #
236
+ # Gli around callbacks wrappano la transizione di stato.
237
+ # Il blocco riceve un altro blocco che deve chiamare per eseguire la transizione.
238
+ #
239
+ # @yield Blocco da eseguire, riceve un blocco da chiamare
240
+ #
241
+ # @example
242
+ # around do |transition|
243
+ # log_start
244
+ # transition.call
245
+ # log_end
246
+ # end
247
+ #
248
+ def around(&block)
249
+ raise StateableError, "around can only be called inside a transition block" unless @current_transition
250
+ raise ArgumentError, "around requires a block" unless block_given?
251
+
252
+ @current_transition[:around_callbacks] << block
253
+ end
254
+
255
+ # Specifica il nome della tabella per state transitions
256
+ #
257
+ # @param name [String, Symbol] Nome della tabella
258
+ #
259
+ # @example Default (state_transitions)
260
+ # stateable do
261
+ # # Uses 'state_transitions' table by default
262
+ # end
263
+ #
264
+ # @example Custom table name
265
+ # stateable do
266
+ # transitions_table 'order_transitions'
267
+ # end
268
+ #
269
+ # @example Shared table across models
270
+ # class Order < ApplicationRecord
271
+ # stateable do
272
+ # transitions_table 'transitions'
273
+ # end
274
+ # end
275
+ #
276
+ # class Article < ApplicationRecord
277
+ # stateable do
278
+ # transitions_table 'transitions' # Same table
279
+ # end
280
+ # end
281
+ #
282
+ def transitions_table(name)
283
+ @table_name = name.to_s
284
+ end
285
+
286
+ # Restituisce la configurazione completa
287
+ #
288
+ # @return [Hash] Configurazione con stati e transizioni
289
+ #
290
+ def to_h
291
+ {
292
+ states: @states,
293
+ transitions: @transitions,
294
+ initial_state: @initial_state,
295
+ table_name: @table_name
296
+ }
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterModel
4
+ module Stateable
5
+ # Base error for all Stateable errors
6
+ class StateableError < StandardError; end
7
+
8
+ # Raised when Stateable is not enabled but methods are called
9
+ class NotEnabledError < StateableError
10
+ def initialize(msg = nil)
11
+ super(msg || "Stateable is not enabled. Add 'stateable do...end' to your model.")
12
+ end
13
+ end
14
+
15
+ # Raised when an invalid state is referenced
16
+ class InvalidStateError < StateableError
17
+ def initialize(state)
18
+ super("Invalid state: #{state.inspect}")
19
+ end
20
+ end
21
+
22
+ # Raised when trying to transition to an invalid state from current state
23
+ class InvalidTransitionError < StateableError
24
+ def initialize(event, from_state, to_state)
25
+ super("Cannot transition from #{from_state.inspect} to #{to_state.inspect} via #{event.inspect}")
26
+ end
27
+ end
28
+
29
+ # Raised when a guard condition fails
30
+ class GuardFailedError < StateableError
31
+ def initialize(event, guard_description = nil)
32
+ msg = "Guard failed for transition #{event.inspect}"
33
+ msg += ": #{guard_description}" if guard_description
34
+ super(msg)
35
+ end
36
+ end
37
+
38
+ # Raised when a transition validation fails
39
+ class ValidationFailedError < StateableError
40
+ def initialize(event, errors)
41
+ super("Validation failed for transition #{event.inspect}: #{errors.full_messages.join(', ')}")
42
+ end
43
+ end
44
+ end
45
+ end
@@ -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