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.
- checksums.yaml +4 -4
- data/README.md +205 -14
- data/lib/state_machines/branch.rb +20 -17
- data/lib/state_machines/callback.rb +13 -12
- data/lib/state_machines/core.rb +3 -3
- data/lib/state_machines/core_ext/class/state_machine.rb +2 -0
- data/lib/state_machines/core_ext.rb +2 -0
- data/lib/state_machines/error.rb +7 -4
- data/lib/state_machines/eval_helpers.rb +93 -26
- data/lib/state_machines/event.rb +41 -29
- data/lib/state_machines/event_collection.rb +6 -5
- data/lib/state_machines/extensions.rb +7 -5
- data/lib/state_machines/helper_module.rb +3 -1
- data/lib/state_machines/integrations/base.rb +3 -1
- data/lib/state_machines/integrations.rb +13 -14
- data/lib/state_machines/machine/action_hooks.rb +53 -0
- data/lib/state_machines/machine/callbacks.rb +59 -0
- data/lib/state_machines/machine/class_methods.rb +93 -0
- data/lib/state_machines/machine/configuration.rb +124 -0
- data/lib/state_machines/machine/event_methods.rb +59 -0
- data/lib/state_machines/machine/helper_generators.rb +125 -0
- data/lib/state_machines/machine/integration.rb +70 -0
- data/lib/state_machines/machine/parsing.rb +77 -0
- data/lib/state_machines/machine/rendering.rb +17 -0
- data/lib/state_machines/machine/scoping.rb +44 -0
- data/lib/state_machines/machine/state_methods.rb +101 -0
- data/lib/state_machines/machine/utilities.rb +85 -0
- data/lib/state_machines/machine/validation.rb +39 -0
- data/lib/state_machines/machine.rb +83 -673
- data/lib/state_machines/machine_collection.rb +23 -15
- data/lib/state_machines/macro_methods.rb +4 -2
- data/lib/state_machines/matcher.rb +8 -5
- data/lib/state_machines/matcher_helpers.rb +3 -1
- data/lib/state_machines/node_collection.rb +23 -18
- data/lib/state_machines/options_validator.rb +72 -0
- data/lib/state_machines/path.rb +7 -5
- data/lib/state_machines/path_collection.rb +7 -4
- data/lib/state_machines/state.rb +76 -47
- data/lib/state_machines/state_collection.rb +5 -3
- data/lib/state_machines/state_context.rb +11 -8
- data/lib/state_machines/stdio_renderer.rb +74 -0
- data/lib/state_machines/syntax_validator.rb +57 -0
- data/lib/state_machines/test_helper.rb +568 -0
- data/lib/state_machines/transition.rb +45 -41
- data/lib/state_machines/transition_collection.rb +27 -26
- data/lib/state_machines/version.rb +3 -1
- data/lib/state_machines.rb +4 -1
- metadata +32 -16
- 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
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
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
|
-
#
|
76
|
-
args
|
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
|
-
|
80
|
-
|
81
|
-
|
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
|
-
|
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
|
data/lib/state_machines/event.rb
CHANGED
@@ -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 =
|
34
|
-
|
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 =
|
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)
|
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(&
|
68
|
-
instance_eval(&
|
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
|
-
|
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
|
-
|
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
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
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, *
|
164
|
+
def fire(object, *)
|
152
165
|
machine.reset(object)
|
153
166
|
|
154
167
|
if (transition = transition_for(object))
|
155
|
-
transition.perform(*
|
168
|
+
transition.perform(*)
|
156
169
|
else
|
157
|
-
on_failure(object, *
|
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
|
-
|
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
|
-
|
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)
|
5
|
-
super(machine, index: [
|
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
|
-
|
135
|
+
private
|
135
136
|
|
136
|
-
def match(requirements)
|
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)
|
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])) ||
|
141
|
+
fire_events(*(events + [run_action])) || raise(StateMachines::InvalidParallelTransition.new(self, events))
|
140
142
|
end
|
141
143
|
|
142
|
-
|
144
|
+
protected
|
143
145
|
|
144
|
-
def initialize_state_machines(options = {}, &
|
145
|
-
self.class.state_machines.initialize_states(self, options, &
|
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
|
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)
|
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
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
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
|
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
|
-
|
49
|
-
# Register all namespaced integrations
|
50
|
-
@integrations
|
51
|
-
end
|
50
|
+
attr_reader :integrations
|
52
51
|
|
53
|
-
|
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
|
-
|
102
|
+
private
|
104
103
|
|
105
104
|
def add(integration)
|
106
|
-
|
107
|
-
|
108
|
-
|
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
|