state_machines 0.20.0 → 0.31.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +154 -18
  3. data/lib/state_machines/branch.rb +30 -17
  4. data/lib/state_machines/callback.rb +12 -13
  5. data/lib/state_machines/core.rb +0 -1
  6. data/lib/state_machines/error.rb +5 -4
  7. data/lib/state_machines/eval_helpers.rb +178 -49
  8. data/lib/state_machines/event.rb +31 -32
  9. data/lib/state_machines/event_collection.rb +4 -5
  10. data/lib/state_machines/extensions.rb +5 -5
  11. data/lib/state_machines/helper_module.rb +1 -1
  12. data/lib/state_machines/integrations/base.rb +1 -1
  13. data/lib/state_machines/integrations.rb +11 -14
  14. data/lib/state_machines/machine/action_hooks.rb +53 -0
  15. data/lib/state_machines/machine/callbacks.rb +59 -0
  16. data/lib/state_machines/machine/class_methods.rb +25 -11
  17. data/lib/state_machines/machine/configuration.rb +124 -0
  18. data/lib/state_machines/machine/event_methods.rb +59 -0
  19. data/lib/state_machines/machine/helper_generators.rb +125 -0
  20. data/lib/state_machines/machine/integration.rb +70 -0
  21. data/lib/state_machines/machine/parsing.rb +77 -0
  22. data/lib/state_machines/machine/rendering.rb +17 -0
  23. data/lib/state_machines/machine/scoping.rb +44 -0
  24. data/lib/state_machines/machine/state_methods.rb +101 -0
  25. data/lib/state_machines/machine/utilities.rb +85 -0
  26. data/lib/state_machines/machine/validation.rb +39 -0
  27. data/lib/state_machines/machine.rb +75 -619
  28. data/lib/state_machines/machine_collection.rb +18 -14
  29. data/lib/state_machines/macro_methods.rb +2 -2
  30. data/lib/state_machines/matcher.rb +6 -6
  31. data/lib/state_machines/matcher_helpers.rb +1 -1
  32. data/lib/state_machines/node_collection.rb +18 -17
  33. data/lib/state_machines/path.rb +2 -4
  34. data/lib/state_machines/path_collection.rb +2 -3
  35. data/lib/state_machines/state.rb +14 -7
  36. data/lib/state_machines/state_collection.rb +3 -3
  37. data/lib/state_machines/state_context.rb +6 -7
  38. data/lib/state_machines/stdio_renderer.rb +16 -16
  39. data/lib/state_machines/syntax_validator.rb +57 -0
  40. data/lib/state_machines/test_helper.rb +290 -27
  41. data/lib/state_machines/transition.rb +57 -46
  42. data/lib/state_machines/transition_collection.rb +22 -25
  43. data/lib/state_machines/version.rb +1 -1
  44. metadata +23 -9
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'syntax_validator'
4
+
3
5
  module StateMachines
4
6
  # Provides a set of helper methods for evaluating methods within the context
5
7
  # of an object.
@@ -45,6 +47,11 @@ module StateMachines
45
47
  # the method defines additional arguments other than the object context,
46
48
  # then all arguments are required.
47
49
  #
50
+ # For guard conditions in state machines, event arguments can be passed
51
+ # automatically based on the guard's arity:
52
+ # - Guards with arity 1 receive only the object (backward compatible)
53
+ # - Guards with arity -1 or > 1 receive object + event arguments
54
+ #
48
55
  # For example,
49
56
  #
50
57
  # person = Person.new('John Smith')
@@ -52,64 +59,186 @@ module StateMachines
52
59
  # evaluate_method(person, lambda {|person| person.name}, 21) # => "John Smith"
53
60
  # evaluate_method(person, lambda {|person, age| "#{person.name} is #{age}"}, 21) # => "John Smith is 21"
54
61
  # evaluate_method(person, lambda {|person, age| "#{person.name} is #{age}"}, 21, 'male') # => ArgumentError: wrong number of arguments (3 for 2)
55
- def evaluate_method(object, method, *args, **kwargs, &block)
62
+ #
63
+ # With event arguments for guards:
64
+ #
65
+ # # Single parameter guard (backward compatible)
66
+ # guard = lambda {|obj| obj.valid? }
67
+ # evaluate_method_with_event_args(object, guard, [arg1, arg2]) # => calls guard.call(object)
68
+ #
69
+ # # Multi-parameter guard (receives event args)
70
+ # guard = lambda {|obj, *args| obj.valid? && args[0] == :force }
71
+ # evaluate_method_with_event_args(object, guard, [:force]) # => calls guard.call(object, :force)
72
+ def evaluate_method(object, method, *args, **, &block)
56
73
  case method
57
- when Symbol
58
- klass = (class << object; self; end)
59
- args = [] if (klass.method_defined?(method) || klass.private_method_defined?(method)) && object.method(method).arity == 0
60
- object.send(method, *args, **kwargs, &block)
61
- when Proc
62
- args.unshift(object)
63
- arity = method.arity
64
- # Handle blocks for Procs
65
- if block_given? && arity != 0
66
- if [1, 2].include?(arity)
67
- # Force the block to be either the only argument or the 2nd one
68
- # after the object (may mean additional arguments get discarded)
69
- args = args[0, arity - 1] + [block]
70
- else
71
- # Tack the block to the end of the args
72
- args << block
73
- end
74
+ in Symbol => sym
75
+ klass = (class << object; self; end)
76
+ args = [] if (klass.method_defined?(sym) || klass.private_method_defined?(sym)) && object.method(sym).arity.zero?
77
+ object.send(sym, *args, **, &block)
78
+ in Proc => proc
79
+ args.unshift(object)
80
+ arity = proc.arity
81
+ # Handle blocks for Procs
82
+ case [block_given?, arity]
83
+ in [true, arity] if arity != 0
84
+ case arity
85
+ in 1 | 2
86
+ # Force the block to be either the only argument or the second one
87
+ # after the object (may mean additional arguments get discarded)
88
+ args = args[0, arity - 1] + [block]
74
89
  else
75
- # These method types are only called with 0, 1, or n arguments
76
- args = args[0, arity] if [0, 1].include?(arity)
90
+ # insert the block to the end of the args
91
+ args << block
77
92
  end
93
+ in [_, 0 | 1]
94
+ # These method types are only called with 0, 1, or n arguments
95
+ args = args[0, arity]
96
+ else
97
+ # No changes needed for other cases
98
+ end
78
99
 
79
100
  # Call the Proc with the arguments
80
- method.call(*args, **kwargs)
101
+ proc.call(*args, **)
81
102
 
82
- when Method
83
- args.unshift(object)
84
- arity = method.arity
103
+ in Method => meth
104
+ args.unshift(object)
105
+ arity = meth.arity
85
106
 
86
- # Methods handle blocks via &block, not as arguments
87
- # Only limit arguments if necessary based on arity
88
- args = args[0, arity] if [0, 1].include?(arity)
107
+ # Methods handle blocks via &block, not as arguments
108
+ # Only limit arguments if necessary based on arity
109
+ args = args[0, arity] if [0, 1].include?(arity)
89
110
 
90
- # Call the Method with the arguments and pass the block
91
- method.call(*args, **kwargs, &block)
92
- when String
93
- if block_given?
94
- if StateMachines::Transition.pause_supported?
95
- eval(method, object.instance_eval { binding }, &block)
96
- else
97
- # Support for JRuby and Truffle Ruby, which don't support binding blocks
98
- eigen = class << object; self; end
99
- eigen.class_eval <<-RUBY, __FILE__, __LINE__ + 1
100
- def __temp_eval_method__(*args, &b)
101
- #{method}
102
- end
103
- RUBY
104
- result = object.__temp_eval_method__(*args, &block)
105
- eigen.send(:remove_method, :__temp_eval_method__)
106
- result
107
- end
108
- else
109
- eval(method, object.instance_eval { binding })
110
- end
111
+ # Call the Method with the arguments and pass the block
112
+ meth.call(*args, **, &block)
113
+ in String => str
114
+ # Input validation for string evaluation
115
+ validate_eval_string(str)
116
+
117
+ case [block_given?, StateMachines::Transition.pause_supported?]
118
+ in [true, true]
119
+ eval(str, object.instance_eval { binding }, &block)
120
+ in [true, false]
121
+ # Support for JRuby and Truffle Ruby, which don't support binding blocks
122
+ # Need to check with @headius, if jruby 10 does now.
123
+ eigen = class << object; self; end
124
+ eigen.class_eval <<-RUBY, __FILE__, __LINE__ + 1
125
+ def __temp_eval_method__(*args, &b)
126
+ #{str}
127
+ end
128
+ RUBY
129
+ result = object.__temp_eval_method__(*args, &block)
130
+ eigen.send(:remove_method, :__temp_eval_method__)
131
+ result
132
+ in [false, _]
133
+ eval(str, object.instance_eval { binding })
134
+ end
135
+ else
136
+ raise ArgumentError, 'Methods must be a symbol denoting the method to call, a block to be invoked, or a string to be evaluated'
137
+ end
138
+ end
139
+
140
+ # Evaluates a guard method with support for event arguments passed to transitions.
141
+ # This method uses arity detection to determine whether to pass event arguments
142
+ # to the guard, ensuring backward compatibility.
143
+ #
144
+ # == Parameters
145
+ # * object - The object context to evaluate within
146
+ # * method - The guard method/proc to evaluate
147
+ # * event_args - Array of arguments passed to the event (optional)
148
+ #
149
+ # == Arity-based behavior
150
+ # * Arity 1: Only passes the object (backward compatible)
151
+ # * Arity -1 or > 1: Passes object + event arguments
152
+ #
153
+ # == Examples
154
+ #
155
+ # # Backward compatible single-parameter guard
156
+ # guard = lambda {|obj| obj.valid? }
157
+ # evaluate_method_with_event_args(object, guard, [:force]) # => calls guard.call(object)
158
+ #
159
+ # # New multi-parameter guard receiving event args
160
+ # guard = lambda {|obj, *args| obj.valid? && args[0] != :skip }
161
+ # evaluate_method_with_event_args(object, guard, [:skip]) # => calls guard.call(object, :skip)
162
+ def evaluate_method_with_event_args(object, method, event_args = [])
163
+ case method
164
+ in Symbol
165
+ # Symbol methods currently don't support event arguments
166
+ # This maintains backward compatibility
167
+ evaluate_method(object, method)
168
+ in Proc => proc
169
+ arity = proc.arity
170
+
171
+ # Arity-based decision for backward compatibility using pattern matching
172
+ case arity
173
+ in 0
174
+ proc.call
175
+ in 1
176
+ proc.call(object)
177
+ in -1
178
+ # Splat parameters: object + all event args
179
+ proc.call(object, *event_args)
180
+ in arity if arity > 1
181
+ # Explicit parameters: object + limited event args
182
+ args_needed = arity - 1 # Subtract 1 for the object parameter
183
+ proc.call(object, *event_args[0, args_needed])
184
+ else
185
+ # Negative arity other than -1 (unlikely but handle gracefully)
186
+ proc.call(object, *event_args)
187
+ end
188
+ in Method => meth
189
+ arity = meth.arity
190
+
191
+ case arity
192
+ in 0
193
+ meth.call
194
+ in 1
195
+ meth.call(object)
196
+ in -1
197
+ meth.call(object, *event_args)
198
+ in arity if arity > 1
199
+ args_needed = arity - 1
200
+ meth.call(object, *event_args[0, args_needed])
111
201
  else
112
- raise ArgumentError, 'Methods must be a symbol denoting the method to call, a block to be invoked, or a string to be evaluated'
202
+ meth.call(object, *event_args)
203
+ end
204
+ in String
205
+ # String evaluation doesn't support event arguments for security
206
+ evaluate_method(object, method)
207
+ else
208
+ # Fall back to standard evaluation
209
+ evaluate_method(object, method)
210
+ end
211
+ end
212
+
213
+ private
214
+
215
+ # Validates string input before eval to prevent code injection
216
+ # This is a basic safety check - not foolproof security
217
+ def validate_eval_string(method_string)
218
+ # Check for obviously dangerous patterns
219
+ dangerous_patterns = [
220
+ /`.*`/, # Backticks (shell execution)
221
+ /system\s*\(/, # System calls
222
+ /exec\s*\(/, # Exec calls
223
+ /eval\s*\(/, # Nested eval
224
+ /require\s+['"]/, # Require statements
225
+ /load\s+['"]/, # Load statements
226
+ /File\./, # File operations
227
+ /IO\./, # IO operations
228
+ /Dir\./, # Directory operations
229
+ /Kernel\./ # Kernel operations
230
+ ]
231
+
232
+ dangerous_patterns.each do |pattern|
233
+ raise SecurityError, "Potentially dangerous code detected in eval string: #{method_string.inspect}" if method_string.match?(pattern)
234
+ end
235
+
236
+ # Basic syntax validation - but allow yield since it's valid in block context
237
+ begin
238
+ test_code = method_string.include?('yield') ? "def dummy_method; #{method_string}; end" : method_string
239
+ SyntaxValidator.validate!(test_code, '(eval)')
240
+ rescue SyntaxError => e
241
+ raise ArgumentError, "Invalid Ruby syntax in eval string: #{e.message}"
113
242
  end
114
243
  end
115
244
  end
@@ -7,7 +7,6 @@ module StateMachines
7
7
  # another. The state that an attribute is transitioned to depends on the
8
8
  # branches configured for the event.
9
9
  class Event
10
-
11
10
  include MatcherHelpers
12
11
 
13
12
  # The state machine for which this event is defined
@@ -34,7 +33,7 @@ module StateMachines
34
33
  #
35
34
  # Configuration options:
36
35
  # * <tt>:human_name</tt> - The human-readable version of this event's name
37
- def initialize(machine, name, options = nil, human_name: nil, **extra_options) #:nodoc:
36
+ def initialize(machine, name, options = nil, human_name: nil, **extra_options) # :nodoc:
38
37
  # Handle both old hash style and new kwargs style for backward compatibility
39
38
  if options.is_a?(Hash)
40
39
  # Old style: initialize(machine, name, {human_name: 'Custom Name'})
@@ -43,6 +42,7 @@ module StateMachines
43
42
  else
44
43
  # New style: initialize(machine, name, human_name: 'Custom Name')
45
44
  raise ArgumentError, "Unexpected positional argument: #{options.inspect}" unless options.nil?
45
+
46
46
  StateMachines::OptionsValidator.assert_valid_keys!(extra_options, :human_name) unless extra_options.empty?
47
47
  end
48
48
 
@@ -63,7 +63,7 @@ module StateMachines
63
63
 
64
64
  # Creates a copy of this event in addition to the list of associated
65
65
  # branches to prevent conflicts across events within a class hierarchy.
66
- def initialize_copy(orig) #:nodoc:
66
+ def initialize_copy(orig) # :nodoc:
67
67
  super
68
68
  @branches = @branches.dup
69
69
  @known_states = @known_states.dup
@@ -77,8 +77,8 @@ module StateMachines
77
77
 
78
78
  # Evaluates the given block within the context of this event. This simply
79
79
  # provides a DSL-like syntax for defining transitions.
80
- def context(&block)
81
- instance_eval(&block)
80
+ def context(&)
81
+ instance_eval(&)
82
82
  end
83
83
 
84
84
  # Creates a new transition that determines what to change the current state
@@ -102,9 +102,7 @@ module StateMachines
102
102
 
103
103
  # Only a certain subset of explicit options are allowed for transition
104
104
  # requirements
105
- if (options.keys - [:from, :to, :on, :except_from, :except_to, :except_on, :if, :unless]).empty?
106
- StateMachines::OptionsValidator.assert_valid_keys!(options, :from, :to, :except_from, :except_to, :if, :unless)
107
- end
105
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :from, :to, :except_from, :except_to, :if, :unless) if (options.keys - %i[from to on except_from except_to except_on if unless]).empty?
108
106
 
109
107
  branches << branch = Branch.new(options.merge(on: name))
110
108
  @known_states |= branch.known_states
@@ -133,24 +131,26 @@ module StateMachines
133
131
  # specified, then this will match any to state.
134
132
  # * <tt>:guard</tt> - Whether to guard transitions with the if/unless
135
133
  # conditionals defined for each one. Default is true.
136
- def transition_for(object, requirements = {})
134
+ #
135
+ # Event arguments are passed to guard conditions if they accept multiple parameters.
136
+ def transition_for(object, requirements = {}, *event_args)
137
137
  StateMachines::OptionsValidator.assert_valid_keys!(requirements, :from, :to, :guard)
138
138
  requirements[:from] = machine.states.match!(object).name unless (custom_from_state = requirements.include?(:from))
139
139
 
140
140
  branches.each do |branch|
141
- if (match = branch.match(object, requirements))
142
- # Branch allows for the transition to occur
143
- from = requirements[:from]
144
- to = if match[:to].is_a?(LoopbackMatcher)
145
- from
146
- else
147
- values = requirements.include?(:to) ? [requirements[:to]].flatten : [from] | machine.states.map { |state| state.name }
148
-
149
- match[:to].filter(values).first
150
- end
151
-
152
- return Transition.new(object, machine, name, from, to, !custom_from_state)
153
- end
141
+ next unless (match = branch.match(object, requirements, event_args))
142
+
143
+ # Branch allows for the transition to occur
144
+ from = requirements[:from]
145
+ to = if match[:to].is_a?(LoopbackMatcher)
146
+ from
147
+ else
148
+ values = requirements.include?(:to) ? [requirements[:to]].flatten : [from] | machine.states.map { |state| state.name }
149
+
150
+ match[:to].filter(values).first
151
+ end
152
+
153
+ return Transition.new(object, machine, name, from, to, !custom_from_state)
154
154
  end
155
155
 
156
156
  # No transition matched
@@ -163,13 +163,13 @@ module StateMachines
163
163
  #
164
164
  # Any additional arguments are passed to the StateMachines::Transition#perform
165
165
  # instance method.
166
- def fire(object, *args)
166
+ def fire(object, *event_args)
167
167
  machine.reset(object)
168
168
 
169
- if (transition = transition_for(object))
170
- transition.perform(*args)
169
+ if (transition = transition_for(object, {}, *event_args))
170
+ transition.perform(*event_args)
171
171
  else
172
- on_failure(object, *args)
172
+ on_failure(object, *event_args)
173
173
  false
174
174
  end
175
175
  end
@@ -194,7 +194,6 @@ module StateMachines
194
194
  @known_states = []
195
195
  end
196
196
 
197
-
198
197
  def draw(graph, options = {}, io = $stdout)
199
198
  machine.renderer.draw_event(self, graph, options, io)
200
199
  end
@@ -207,16 +206,16 @@ module StateMachines
207
206
  # event.transition all - :idling => :parked, :idling => same
208
207
  # event # => #<StateMachines::Event name=:park transitions=[all - :idling => :parked, :idling => same]>
209
208
  def inspect
210
- transitions = branches.map do |branch|
209
+ transitions = branches.flat_map do |branch|
211
210
  branch.state_requirements.map do |state_requirement|
212
211
  "#{state_requirement[:from].description} => #{state_requirement[:to].description}"
213
- end * ', '
214
- end
212
+ end
213
+ end.join(', ')
215
214
 
216
- "#<#{self.class} name=#{name.inspect} transitions=[#{transitions * ', '}]>"
215
+ "#<#{self.class} name=#{name.inspect} transitions=[#{transitions}]>"
217
216
  end
218
217
 
219
- protected
218
+ protected
220
219
 
221
220
  # Add the various instance methods that can transition the object using
222
221
  # the current event
@@ -3,8 +3,8 @@
3
3
  module StateMachines
4
4
  # Represents a collection of events in a state machine
5
5
  class EventCollection < NodeCollection
6
- def initialize(machine) #:nodoc:
7
- super(machine, index: [:name, :qualified_name])
6
+ def initialize(machine) # :nodoc:
7
+ super(machine, index: %i[name qualified_name])
8
8
  end
9
9
 
10
10
  # Gets the list of events that can be fired on the given object.
@@ -130,12 +130,11 @@ module StateMachines
130
130
  false
131
131
  end
132
132
  end
133
-
134
133
  end
135
134
 
136
- private
135
+ private
137
136
 
138
- def match(requirements) #:nodoc:
137
+ def match(requirements) # :nodoc:
139
138
  requirements && requirements[:on] ? [fetch(requirements.delete(:on))] : self
140
139
  end
141
140
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module StateMachines
4
4
  module ClassMethods
5
- def self.extended(base) #:nodoc:
5
+ def self.extended(base) # :nodoc:
6
6
  base.class_eval do
7
7
  @state_machines = MachineCollection.new
8
8
  end
@@ -138,13 +138,13 @@ module StateMachines
138
138
  # vehicle.fire_events!(:ignite, :disable_alarm) # => StateMachines::InvalidParallelTransition: Cannot run events in parallel: ignite, disable_alarm
139
139
  def fire_events!(*events)
140
140
  run_action = [true, false].include?(events.last) ? events.pop : true
141
- fire_events(*(events + [run_action])) || fail(StateMachines::InvalidParallelTransition.new(self, events))
141
+ fire_events(*(events + [run_action])) || raise(StateMachines::InvalidParallelTransition.new(self, events))
142
142
  end
143
143
 
144
- protected
144
+ protected
145
145
 
146
- def initialize_state_machines(options = {}, &block) #:nodoc:
147
- self.class.state_machines.initialize_states(self, options, &block)
146
+ def initialize_state_machines(options = {}, &) # :nodoc:
147
+ self.class.state_machines.initialize_states(self, options, &)
148
148
  end
149
149
  end
150
150
  end
@@ -3,7 +3,7 @@
3
3
  module StateMachines
4
4
  # Represents a type of module that defines instance / class methods for a
5
5
  # state machine
6
- class HelperModule < Module #:nodoc:
6
+ class HelperModule < Module # :nodoc:
7
7
  def initialize(machine, kind)
8
8
  @machine = machine
9
9
  @kind = kind
@@ -35,7 +35,7 @@ module StateMachines
35
35
  end
36
36
  end
37
37
 
38
- def self.included(base) #:nodoc:
38
+ def self.included(base) # :nodoc:
39
39
  base.extend ClassMethods
40
40
  end
41
41
  end
@@ -26,15 +26,15 @@ module StateMachines
26
26
  # Register integration
27
27
  def register(name_or_module)
28
28
  case name_or_module.class.to_s
29
- when 'Module'
30
- add(name_or_module)
31
- else
32
- fail IntegrationError
29
+ when 'Module'
30
+ add(name_or_module)
31
+ else
32
+ raise IntegrationError
33
33
  end
34
34
  true
35
35
  end
36
36
 
37
- def reset #:nodoc:#
37
+ def reset # :nodoc:#
38
38
  @integrations = []
39
39
  end
40
40
 
@@ -47,12 +47,9 @@ module StateMachines
47
47
  # StateMachines::Integrations.register(StateMachines::Integrations::ActiveModel)
48
48
  # StateMachines::Integrations.integrations
49
49
  # # => [StateMachines::Integrations::ActiveModel]
50
- def integrations
51
- # Register all namespaced integrations
52
- @integrations
53
- end
50
+ attr_reader :integrations
54
51
 
55
- alias_method :list, :integrations
52
+ alias list integrations
56
53
 
57
54
  # Attempts to find an integration that matches the given class. This will
58
55
  # look through all of the built-in integrations under the StateMachines::Integrations
@@ -102,12 +99,12 @@ module StateMachines
102
99
  integrations.detect { |integration| integration.integration_name == name } || raise(IntegrationNotFound.new(name))
103
100
  end
104
101
 
105
- private
102
+ private
106
103
 
107
104
  def add(integration)
108
- if integration.respond_to?(:integration_name)
109
- @integrations.insert(0, integration) unless @integrations.include?(integration)
110
- end
105
+ return unless integration.respond_to?(:integration_name)
106
+
107
+ @integrations.insert(0, integration) unless @integrations.include?(integration)
111
108
  end
112
109
  end
113
110
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ class Machine
5
+ module ActionHooks
6
+ protected
7
+
8
+ # Determines whether action helpers should be defined for this machine.
9
+ # This is only true if there is an action configured and no other machines
10
+ # have process this same configuration already.
11
+ def define_action_helpers?
12
+ action && owner_class.state_machines.none? { |_name, machine| machine.action == action && machine != self }
13
+ end
14
+
15
+ # Adds helper methods for automatically firing events when an action
16
+ # is invoked
17
+ def define_action_helpers
18
+ return unless action_hook
19
+
20
+ @action_hook_defined = true
21
+ define_action_hook
22
+ end
23
+
24
+ # Hooks directly into actions by defining the same method in an included
25
+ # module. As a result, when the action gets invoked, any state events
26
+ # defined for the object will get run. Method visibility is preserved.
27
+ def define_action_hook
28
+ action_hook = self.action_hook
29
+ action = self.action
30
+ private_action_hook = owner_class.private_method_defined?(action_hook)
31
+
32
+ # Only define helper if it hasn't
33
+ define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
34
+ def #{action_hook}(*)
35
+ self.class.state_machines.transitions(self, #{action.inspect}).perform { super }
36
+ end
37
+
38
+ private #{action_hook.inspect} if #{private_action_hook}
39
+ END_EVAL
40
+ end
41
+
42
+ # The method to hook into for triggering transitions when invoked. By
43
+ # default, this is the action configured for the machine.
44
+ #
45
+ # Since the default hook technique relies on module inheritance, the
46
+ # action must be defined in an ancestor of the owner classs in order for
47
+ # it to be the action hook.
48
+ def action_hook
49
+ action && owner_class_ancestor_has_method?(:instance, action) ? action : nil
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ class Machine
5
+ module Callbacks
6
+ # Creates a callback that will be invoked *before* a transition is
7
+ # performed so long as the given requirements match the transition.
8
+ def before_transition(*args, **options, &)
9
+ # Extract legacy positional arguments and merge with keyword options
10
+ parsed_options = parse_callback_arguments(args, options)
11
+
12
+ # Only validate callback-specific options, not state transition requirements
13
+ callback_options = parsed_options.slice(:do, :if, :unless, :bind_to_object, :terminator)
14
+ StateMachines::OptionsValidator.assert_valid_keys!(callback_options, :do, :if, :unless, :bind_to_object, :terminator)
15
+
16
+ add_callback(:before, parsed_options, &)
17
+ end
18
+
19
+ # Creates a callback that will be invoked *after* a transition is
20
+ # performed so long as the given requirements match the transition.
21
+ def after_transition(*args, **options, &)
22
+ # Extract legacy positional arguments and merge with keyword options
23
+ parsed_options = parse_callback_arguments(args, options)
24
+
25
+ # Only validate callback-specific options, not state transition requirements
26
+ callback_options = parsed_options.slice(:do, :if, :unless, :bind_to_object, :terminator)
27
+ StateMachines::OptionsValidator.assert_valid_keys!(callback_options, :do, :if, :unless, :bind_to_object, :terminator)
28
+
29
+ add_callback(:after, parsed_options, &)
30
+ end
31
+
32
+ # Creates a callback that will be invoked *around* a transition so long
33
+ # as the given requirements match the transition.
34
+ def around_transition(*args, **options, &)
35
+ # Extract legacy positional arguments and merge with keyword options
36
+ parsed_options = parse_callback_arguments(args, options)
37
+
38
+ # Only validate callback-specific options, not state transition requirements
39
+ callback_options = parsed_options.slice(:do, :if, :unless, :bind_to_object, :terminator)
40
+ StateMachines::OptionsValidator.assert_valid_keys!(callback_options, :do, :if, :unless, :bind_to_object, :terminator)
41
+
42
+ add_callback(:around, parsed_options, &)
43
+ end
44
+
45
+ # Creates a callback that will be invoked after a transition has failed
46
+ # to be performed.
47
+ def after_failure(*args, **options, &)
48
+ # Extract legacy positional arguments and merge with keyword options
49
+ parsed_options = parse_callback_arguments(args, options)
50
+
51
+ # Only validate callback-specific options, not state transition requirements
52
+ callback_options = parsed_options.slice(:do, :if, :unless, :bind_to_object, :terminator)
53
+ StateMachines::OptionsValidator.assert_valid_keys!(callback_options, :do, :if, :unless, :bind_to_object, :terminator)
54
+
55
+ add_callback(:failure, parsed_options, &)
56
+ end
57
+ end
58
+ end
59
+ end