state_machines 0.6.0 → 0.30.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +205 -14
  3. data/lib/state_machines/branch.rb +20 -17
  4. data/lib/state_machines/callback.rb +13 -12
  5. data/lib/state_machines/core.rb +3 -3
  6. data/lib/state_machines/core_ext/class/state_machine.rb +2 -0
  7. data/lib/state_machines/core_ext.rb +2 -0
  8. data/lib/state_machines/error.rb +7 -4
  9. data/lib/state_machines/eval_helpers.rb +93 -26
  10. data/lib/state_machines/event.rb +41 -29
  11. data/lib/state_machines/event_collection.rb +6 -5
  12. data/lib/state_machines/extensions.rb +7 -5
  13. data/lib/state_machines/helper_module.rb +3 -1
  14. data/lib/state_machines/integrations/base.rb +3 -1
  15. data/lib/state_machines/integrations.rb +13 -14
  16. data/lib/state_machines/machine/action_hooks.rb +53 -0
  17. data/lib/state_machines/machine/callbacks.rb +59 -0
  18. data/lib/state_machines/machine/class_methods.rb +93 -0
  19. data/lib/state_machines/machine/configuration.rb +124 -0
  20. data/lib/state_machines/machine/event_methods.rb +59 -0
  21. data/lib/state_machines/machine/helper_generators.rb +125 -0
  22. data/lib/state_machines/machine/integration.rb +70 -0
  23. data/lib/state_machines/machine/parsing.rb +77 -0
  24. data/lib/state_machines/machine/rendering.rb +17 -0
  25. data/lib/state_machines/machine/scoping.rb +44 -0
  26. data/lib/state_machines/machine/state_methods.rb +101 -0
  27. data/lib/state_machines/machine/utilities.rb +85 -0
  28. data/lib/state_machines/machine/validation.rb +39 -0
  29. data/lib/state_machines/machine.rb +83 -673
  30. data/lib/state_machines/machine_collection.rb +23 -15
  31. data/lib/state_machines/macro_methods.rb +4 -2
  32. data/lib/state_machines/matcher.rb +8 -5
  33. data/lib/state_machines/matcher_helpers.rb +3 -1
  34. data/lib/state_machines/node_collection.rb +23 -18
  35. data/lib/state_machines/options_validator.rb +72 -0
  36. data/lib/state_machines/path.rb +7 -5
  37. data/lib/state_machines/path_collection.rb +7 -4
  38. data/lib/state_machines/state.rb +76 -47
  39. data/lib/state_machines/state_collection.rb +5 -3
  40. data/lib/state_machines/state_context.rb +11 -8
  41. data/lib/state_machines/stdio_renderer.rb +74 -0
  42. data/lib/state_machines/syntax_validator.rb +57 -0
  43. data/lib/state_machines/test_helper.rb +568 -0
  44. data/lib/state_machines/transition.rb +45 -41
  45. data/lib/state_machines/transition_collection.rb +27 -26
  46. data/lib/state_machines/version.rb +3 -1
  47. data/lib/state_machines.rb +4 -1
  48. metadata +32 -16
  49. data/lib/state_machines/assertions.rb +0 -40
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'syntax_validator'
4
+
1
5
  module StateMachines
2
6
  # Provides a set of helper methods for evaluating methods within the context
3
7
  # of an object.
@@ -50,37 +54,100 @@ module StateMachines
50
54
  # evaluate_method(person, lambda {|person| person.name}, 21) # => "John Smith"
51
55
  # evaluate_method(person, lambda {|person, age| "#{person.name} is #{age}"}, 21) # => "John Smith is 21"
52
56
  # evaluate_method(person, lambda {|person, age| "#{person.name} is #{age}"}, 21, 'male') # => ArgumentError: wrong number of arguments (3 for 2)
53
- def evaluate_method(object, method, *args, &block)
57
+ def evaluate_method(object, method, *args, **, &block)
54
58
  case method
55
- when Symbol
56
- klass = (class << object; self; end)
57
- args = [] if (klass.method_defined?(method) || klass.private_method_defined?(method)) && object.method(method).arity == 0
58
- object.send(method, *args, &block)
59
- when Proc, Method
60
- args.unshift(object)
61
- arity = method.arity
62
-
63
- # Procs don't support blocks in < Ruby 1.9, so it's tacked on as an
64
- # argument for consistency across versions of Ruby
65
- if block_given? && Proc === method && 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
59
+ when Symbol
60
+ klass = (class << object; self; end)
61
+ args = [] if (klass.method_defined?(method) || klass.private_method_defined?(method)) && object.method(method).arity == 0
62
+ object.send(method, *args, **, &block)
63
+ when Proc
64
+ args.unshift(object)
65
+ arity = method.arity
66
+ # Handle blocks for Procs
67
+ if block_given? && arity != 0
68
+ if [1, 2].include?(arity)
69
+ # Force the block to be either the only argument or the second one
70
+ # after the object (may mean additional arguments get discarded)
71
+ args = args[0, arity - 1] + [block]
74
72
  else
75
- # These method types are only called with 0, 1, or n arguments
76
- args = args[0, arity] if [0, 1].include?(arity)
73
+ # insert the block to the end of the args
74
+ args << block
77
75
  end
76
+ elsif [0, 1].include?(arity)
77
+ # These method types are only called with 0, 1, or n arguments
78
+ args = args[0, arity]
79
+ end
80
+
81
+ # Call the Proc with the arguments
82
+ method.call(*args, **)
78
83
 
79
- method.is_a?(Proc) ? method.call(*args) : method.call(*args, &block)
80
- when String
81
- eval(method, object.instance_eval { binding }, &block)
84
+ when Method
85
+ args.unshift(object)
86
+ arity = method.arity
87
+
88
+ # Methods handle blocks via &block, not as arguments
89
+ # Only limit arguments if necessary based on arity
90
+ args = args[0, arity] if [0, 1].include?(arity)
91
+
92
+ # Call the Method with the arguments and pass the block
93
+ method.call(*args, **, &block)
94
+ when String
95
+ # Input validation for string evaluation
96
+ validate_eval_string(method)
97
+
98
+ if block_given?
99
+ if StateMachines::Transition.pause_supported?
100
+ eval(method, object.instance_eval { binding }, &block)
101
+ else
102
+ # Support for JRuby and Truffle Ruby, which don't support binding blocks
103
+ # Need to check with @headius, if jruby 10 does now.
104
+ eigen = class << object; self; end
105
+ eigen.class_eval <<-RUBY, __FILE__, __LINE__ + 1
106
+ def __temp_eval_method__(*args, &b)
107
+ #{method}
108
+ end
109
+ RUBY
110
+ result = object.__temp_eval_method__(*args, &block)
111
+ eigen.send(:remove_method, :__temp_eval_method__)
112
+ result
113
+ end
82
114
  else
83
- raise ArgumentError, 'Methods must be a symbol denoting the method to call, a block to be invoked, or a string to be evaluated'
115
+ eval(method, object.instance_eval { binding })
116
+ end
117
+ else
118
+ raise ArgumentError, 'Methods must be a symbol denoting the method to call, a block to be invoked, or a string to be evaluated'
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ # Validates string input before eval to prevent code injection
125
+ # This is a basic safety check - not foolproof security
126
+ def validate_eval_string(method_string)
127
+ # Check for obviously dangerous patterns
128
+ dangerous_patterns = [
129
+ /`.*`/, # Backticks (shell execution)
130
+ /system\s*\(/, # System calls
131
+ /exec\s*\(/, # Exec calls
132
+ /eval\s*\(/, # Nested eval
133
+ /require\s+['"]/, # Require statements
134
+ /load\s+['"]/, # Load statements
135
+ /File\./, # File operations
136
+ /IO\./, # IO operations
137
+ /Dir\./, # Directory operations
138
+ /Kernel\./ # Kernel operations
139
+ ]
140
+
141
+ dangerous_patterns.each do |pattern|
142
+ raise SecurityError, "Potentially dangerous code detected in eval string: #{method_string.inspect}" if method_string.match?(pattern)
143
+ end
144
+
145
+ # Basic syntax validation - but allow yield since it's valid in block context
146
+ begin
147
+ test_code = method_string.include?('yield') ? "def dummy_method; #{method_string}; end" : method_string
148
+ SyntaxValidator.validate!(test_code, '(eval)')
149
+ rescue SyntaxError => e
150
+ raise ArgumentError, "Invalid Ruby syntax in eval string: #{e.message}"
84
151
  end
85
152
  end
86
153
  end
@@ -1,9 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'options_validator'
4
+
1
5
  module StateMachines
2
6
  # An event defines an action that transitions an attribute from one state to
3
7
  # another. The state that an attribute is transitioned to depends on the
4
8
  # branches configured for the event.
5
9
  class Event
6
-
7
10
  include MatcherHelpers
8
11
 
9
12
  # The state machine for which this event is defined
@@ -30,13 +33,23 @@ module StateMachines
30
33
  #
31
34
  # Configuration options:
32
35
  # * <tt>:human_name</tt> - The human-readable version of this event's name
33
- def initialize(machine, name, options = {}) #:nodoc:
34
- options.assert_valid_keys(:human_name)
36
+ def initialize(machine, name, options = nil, human_name: nil, **extra_options) # :nodoc:
37
+ # Handle both old hash style and new kwargs style for backward compatibility
38
+ if options.is_a?(Hash)
39
+ # Old style: initialize(machine, name, {human_name: 'Custom Name'})
40
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :human_name)
41
+ human_name = options[:human_name]
42
+ else
43
+ # New style: initialize(machine, name, human_name: 'Custom Name')
44
+ raise ArgumentError, "Unexpected positional argument: #{options.inspect}" unless options.nil?
45
+
46
+ StateMachines::OptionsValidator.assert_valid_keys!(extra_options, :human_name) unless extra_options.empty?
47
+ end
35
48
 
36
49
  @machine = machine
37
50
  @name = name
38
51
  @qualified_name = machine.namespace ? :"#{name}_#{machine.namespace}" : name
39
- @human_name = options[:human_name] || @name.to_s.tr('_', ' ')
52
+ @human_name = human_name || @name.to_s.tr('_', ' ')
40
53
  reset
41
54
 
42
55
  # Output a warning if another event has a conflicting qualified name
@@ -50,7 +63,7 @@ module StateMachines
50
63
 
51
64
  # Creates a copy of this event in addition to the list of associated
52
65
  # branches to prevent conflicts across events within a class hierarchy.
53
- def initialize_copy(orig) #:nodoc:
66
+ def initialize_copy(orig) # :nodoc:
54
67
  super
55
68
  @branches = @branches.dup
56
69
  @known_states = @known_states.dup
@@ -64,8 +77,8 @@ module StateMachines
64
77
 
65
78
  # Evaluates the given block within the context of this event. This simply
66
79
  # provides a DSL-like syntax for defining transitions.
67
- def context(&block)
68
- instance_eval(&block)
80
+ def context(&)
81
+ instance_eval(&)
69
82
  end
70
83
 
71
84
  # Creates a new transition that determines what to change the current state
@@ -89,7 +102,7 @@ module StateMachines
89
102
 
90
103
  # Only a certain subset of explicit options are allowed for transition
91
104
  # requirements
92
- options.assert_valid_keys(:from, :to, :except_from, :except_to, :if, :unless) if (options.keys - [:from, :to, :on, :except_from, :except_to, :except_on, :if, :unless]).empty?
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?
93
106
 
94
107
  branches << branch = Branch.new(options.merge(on: name))
95
108
  @known_states |= branch.known_states
@@ -119,23 +132,23 @@ module StateMachines
119
132
  # * <tt>:guard</tt> - Whether to guard transitions with the if/unless
120
133
  # conditionals defined for each one. Default is true.
121
134
  def transition_for(object, requirements = {})
122
- requirements.assert_valid_keys(:from, :to, :guard)
135
+ StateMachines::OptionsValidator.assert_valid_keys!(requirements, :from, :to, :guard)
123
136
  requirements[:from] = machine.states.match!(object).name unless (custom_from_state = requirements.include?(:from))
124
137
 
125
138
  branches.each do |branch|
126
- if (match = branch.match(object, requirements))
127
- # Branch allows for the transition to occur
128
- from = requirements[:from]
129
- to = if match[:to].is_a?(LoopbackMatcher)
130
- from
131
- else
132
- values = requirements.include?(:to) ? [requirements[:to]].flatten : [from] | machine.states.map { |state| state.name }
133
-
134
- match[:to].filter(values).first
135
- end
136
-
137
- return Transition.new(object, machine, name, from, to, !custom_from_state)
138
- end
139
+ next unless (match = branch.match(object, requirements))
140
+
141
+ # Branch allows for the transition to occur
142
+ from = requirements[:from]
143
+ to = if match[:to].is_a?(LoopbackMatcher)
144
+ from
145
+ else
146
+ values = requirements.include?(:to) ? [requirements[:to]].flatten : [from] | machine.states.map { |state| state.name }
147
+
148
+ match[:to].filter(values).first
149
+ end
150
+
151
+ return Transition.new(object, machine, name, from, to, !custom_from_state)
139
152
  end
140
153
 
141
154
  # No transition matched
@@ -148,13 +161,13 @@ module StateMachines
148
161
  #
149
162
  # Any additional arguments are passed to the StateMachines::Transition#perform
150
163
  # instance method.
151
- def fire(object, *args)
164
+ def fire(object, *)
152
165
  machine.reset(object)
153
166
 
154
167
  if (transition = transition_for(object))
155
- transition.perform(*args)
168
+ transition.perform(*)
156
169
  else
157
- on_failure(object, *args)
170
+ on_failure(object, *)
158
171
  false
159
172
  end
160
173
  end
@@ -179,9 +192,8 @@ module StateMachines
179
192
  @known_states = []
180
193
  end
181
194
 
182
-
183
- def draw(graph, options = {})
184
- fail NotImplementedError
195
+ def draw(graph, options = {}, io = $stdout)
196
+ machine.renderer.draw_event(self, graph, options, io)
185
197
  end
186
198
 
187
199
  # Generates a nicely formatted description of this event's contents.
@@ -201,7 +213,7 @@ module StateMachines
201
213
  "#<#{self.class} name=#{name.inspect} transitions=[#{transitions * ', '}]>"
202
214
  end
203
215
 
204
- protected
216
+ protected
205
217
 
206
218
  # Add the various instance methods that can transition the object using
207
219
  # the current event
@@ -1,8 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module StateMachines
2
4
  # Represents a collection of events in a state machine
3
5
  class EventCollection < NodeCollection
4
- def initialize(machine) #:nodoc:
5
- super(machine, index: [:name, :qualified_name])
6
+ def initialize(machine) # :nodoc:
7
+ super(machine, index: %i[name qualified_name])
6
8
  end
7
9
 
8
10
  # Gets the list of events that can be fired on the given object.
@@ -128,12 +130,11 @@ module StateMachines
128
130
  false
129
131
  end
130
132
  end
131
-
132
133
  end
133
134
 
134
- private
135
+ private
135
136
 
136
- def match(requirements) #:nodoc:
137
+ def match(requirements) # :nodoc:
137
138
  requirements && requirements[:on] ? [fetch(requirements.delete(:on))] : self
138
139
  end
139
140
  end
@@ -1,6 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module StateMachines
2
4
  module ClassMethods
3
- def self.extended(base) #:nodoc:
5
+ def self.extended(base) # :nodoc:
4
6
  base.class_eval do
5
7
  @state_machines = MachineCollection.new
6
8
  end
@@ -136,13 +138,13 @@ module StateMachines
136
138
  # vehicle.fire_events!(:ignite, :disable_alarm) # => StateMachines::InvalidParallelTransition: Cannot run events in parallel: ignite, disable_alarm
137
139
  def fire_events!(*events)
138
140
  run_action = [true, false].include?(events.last) ? events.pop : true
139
- fire_events(*(events + [run_action])) || fail(StateMachines::InvalidParallelTransition.new(self, events))
141
+ fire_events(*(events + [run_action])) || raise(StateMachines::InvalidParallelTransition.new(self, events))
140
142
  end
141
143
 
142
- protected
144
+ protected
143
145
 
144
- def initialize_state_machines(options = {}, &block) #:nodoc:
145
- 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, &)
146
148
  end
147
149
  end
148
150
  end
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module StateMachines
2
4
  # Represents a type of module that defines instance / class methods for a
3
5
  # state machine
4
- class HelperModule < Module #:nodoc:
6
+ class HelperModule < Module # :nodoc:
5
7
  def initialize(machine, kind)
6
8
  @machine = machine
7
9
  @kind = kind
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module StateMachines
2
4
  module Integrations
3
5
  # Provides a set of base helpers for managing individual integrations
@@ -33,7 +35,7 @@ module StateMachines
33
35
  end
34
36
  end
35
37
 
36
- def self.included(base) #:nodoc:
38
+ def self.included(base) # :nodoc:
37
39
  base.extend ClassMethods
38
40
  end
39
41
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module StateMachines
2
4
  # Integrations allow state machines to take advantage of features within the
3
5
  # context of a particular library. This is currently most useful with
@@ -24,15 +26,15 @@ module StateMachines
24
26
  # Register integration
25
27
  def register(name_or_module)
26
28
  case name_or_module.class.to_s
27
- when 'Module'
28
- add(name_or_module)
29
- else
30
- fail IntegrationError
29
+ when 'Module'
30
+ add(name_or_module)
31
+ else
32
+ raise IntegrationError
31
33
  end
32
34
  true
33
35
  end
34
36
 
35
- def reset #:nodoc:#
37
+ def reset # :nodoc:#
36
38
  @integrations = []
37
39
  end
38
40
 
@@ -45,12 +47,9 @@ module StateMachines
45
47
  # StateMachines::Integrations.register(StateMachines::Integrations::ActiveModel)
46
48
  # StateMachines::Integrations.integrations
47
49
  # # => [StateMachines::Integrations::ActiveModel]
48
- def integrations
49
- # Register all namespaced integrations
50
- @integrations
51
- end
50
+ attr_reader :integrations
52
51
 
53
- alias_method :list, :integrations
52
+ alias list integrations
54
53
 
55
54
  # Attempts to find an integration that matches the given class. This will
56
55
  # look through all of the built-in integrations under the StateMachines::Integrations
@@ -100,12 +99,12 @@ module StateMachines
100
99
  integrations.detect { |integration| integration.integration_name == name } || raise(IntegrationNotFound.new(name))
101
100
  end
102
101
 
103
- private
102
+ private
104
103
 
105
104
  def add(integration)
106
- if integration.respond_to?(:integration_name)
107
- @integrations.insert(0, integration) unless @integrations.include?(integration)
108
- end
105
+ return unless integration.respond_to?(:integration_name)
106
+
107
+ @integrations.insert(0, integration) unless @integrations.include?(integration)
109
108
  end
110
109
  end
111
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
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ class Machine
5
+ module ClassMethods
6
+ # Attempts to find or create a state machine for the given class. For
7
+ # example,
8
+ #
9
+ # StateMachines::Machine.find_or_create(Vehicle)
10
+ # StateMachines::Machine.find_or_create(Vehicle, :initial => :parked)
11
+ # StateMachines::Machine.find_or_create(Vehicle, :status)
12
+ # StateMachines::Machine.find_or_create(Vehicle, :status, :initial => :parked)
13
+ #
14
+ # If a machine of the given name already exists in one of the class's
15
+ # superclasses, then a copy of that machine will be created and stored
16
+ # in the new owner class (the original will remain unchanged).
17
+ def find_or_create(owner_class, *args, &)
18
+ options = args.last.is_a?(Hash) ? args.pop : {}
19
+ name = args.first || :state
20
+
21
+ # Find an existing machine
22
+ machine = (owner_class.respond_to?(:state_machines) &&
23
+ ((args.first && owner_class.state_machines[name]) || (!args.first &&
24
+ owner_class.state_machines.values.first))) || nil
25
+
26
+ if machine
27
+ # Only create a new copy if changes are being made to the machine in
28
+ # a subclass
29
+ if machine.owner_class != owner_class && (options.any? || block_given?)
30
+ machine = machine.clone
31
+ machine.initial_state = options[:initial] if options.include?(:initial)
32
+ machine.owner_class = owner_class
33
+ end
34
+
35
+ # Evaluate DSL
36
+ machine.instance_eval(&) if block_given?
37
+ else
38
+ # No existing machine: create a new one
39
+ machine = new(owner_class, name, options, &)
40
+ end
41
+
42
+ machine
43
+ end
44
+
45
+ def draw(*)
46
+ raise NotImplementedError
47
+ end
48
+
49
+ # Default messages to use for validation errors in ORM integrations
50
+ # Thread-safe access via atomic operations on simple values
51
+ attr_accessor :ignore_method_conflicts
52
+
53
+ def default_messages
54
+ @default_messages ||= {
55
+ invalid: 'is invalid',
56
+ invalid_event: 'cannot transition when %s',
57
+ invalid_transition: 'cannot transition via "%1$s"'
58
+ }.freeze
59
+ end
60
+
61
+ def default_messages=(messages)
62
+ # Atomic replacement with frozen object
63
+ @default_messages = deep_freeze_hash(messages)
64
+ end
65
+
66
+ def replace_messages(message_hash)
67
+ # Atomic replacement: read current messages, merge with new ones, replace atomically
68
+ current_messages = @default_messages || {}
69
+ merged_messages = current_messages.merge(message_hash)
70
+ @default_messages = deep_freeze_hash(merged_messages)
71
+ end
72
+
73
+ attr_writer :renderer
74
+
75
+ def renderer
76
+ return @renderer if @renderer
77
+
78
+ STDIORenderer
79
+ end
80
+
81
+ private
82
+
83
+ # Deep freezes a hash and all its string values for thread safety
84
+ def deep_freeze_hash(hash)
85
+ hash.each_with_object({}) do |(key, value), frozen_hash|
86
+ frozen_key = key.respond_to?(:freeze) ? key.freeze : key
87
+ frozen_value = value.respond_to?(:freeze) ? value.freeze : value
88
+ frozen_hash[frozen_key] = frozen_value
89
+ end.freeze
90
+ end
91
+ end
92
+ end
93
+ end