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
@@ -0,0 +1,124 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module Configuration
|
6
|
+
# Initializes a new state machine with the given configuration.
|
7
|
+
def initialize(owner_class, *args, &)
|
8
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
9
|
+
StateMachines::OptionsValidator.assert_valid_keys!(options, :attribute, :initial, :initialize, :action, :plural, :namespace, :integration, :messages, :use_transactions)
|
10
|
+
|
11
|
+
# Find an integration that matches this machine's owner class
|
12
|
+
@integration = if options.include?(:integration)
|
13
|
+
options[:integration] && StateMachines::Integrations.find_by_name(options[:integration])
|
14
|
+
else
|
15
|
+
StateMachines::Integrations.match(owner_class)
|
16
|
+
end
|
17
|
+
|
18
|
+
if @integration
|
19
|
+
extend @integration
|
20
|
+
options = (@integration.defaults || {}).merge(options)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Add machine-wide defaults
|
24
|
+
options = { use_transactions: true, initialize: true }.merge(options)
|
25
|
+
|
26
|
+
# Set machine configuration
|
27
|
+
@name = args.first || :state
|
28
|
+
@attribute = options[:attribute] || @name
|
29
|
+
@events = EventCollection.new(self)
|
30
|
+
@states = StateCollection.new(self)
|
31
|
+
@callbacks = { before: [], after: [], failure: [] }
|
32
|
+
@namespace = options[:namespace]
|
33
|
+
@messages = options[:messages] || {}
|
34
|
+
@action = options[:action]
|
35
|
+
@use_transactions = options[:use_transactions]
|
36
|
+
@initialize_state = options[:initialize]
|
37
|
+
@action_hook_defined = false
|
38
|
+
self.owner_class = owner_class
|
39
|
+
|
40
|
+
# Merge with sibling machine configurations
|
41
|
+
add_sibling_machine_configs
|
42
|
+
|
43
|
+
# Define class integration
|
44
|
+
define_helpers
|
45
|
+
define_scopes(options[:plural])
|
46
|
+
after_initialize
|
47
|
+
|
48
|
+
# Evaluate DSL
|
49
|
+
instance_eval(&) if block_given?
|
50
|
+
self.initial_state = options[:initial] unless sibling_machines.any?
|
51
|
+
end
|
52
|
+
|
53
|
+
# Creates a copy of this machine in addition to copies of each associated
|
54
|
+
# event/states/callback, so that the modifications to those collections do
|
55
|
+
# not affect the original machine.
|
56
|
+
def initialize_copy(orig) # :nodoc:
|
57
|
+
super
|
58
|
+
|
59
|
+
@events = @events.dup
|
60
|
+
@events.machine = self
|
61
|
+
@states = @states.dup
|
62
|
+
@states.machine = self
|
63
|
+
@callbacks = { before: @callbacks[:before].dup, after: @callbacks[:after].dup, failure: @callbacks[:failure].dup }
|
64
|
+
end
|
65
|
+
|
66
|
+
# Sets the class which is the owner of this state machine. Any methods
|
67
|
+
# generated by states, events, or other parts of the machine will be defined
|
68
|
+
# on the given owner class.
|
69
|
+
def owner_class=(klass)
|
70
|
+
@owner_class = klass
|
71
|
+
|
72
|
+
# Create modules for extending the class with state/event-specific methods
|
73
|
+
@helper_modules = helper_modules = { instance: HelperModule.new(self, :instance), class: HelperModule.new(self, :class) }
|
74
|
+
owner_class.class_eval do
|
75
|
+
extend helper_modules[:class]
|
76
|
+
include helper_modules[:instance]
|
77
|
+
end
|
78
|
+
|
79
|
+
# Add class-/instance-level methods to the owner class for state initialization
|
80
|
+
unless owner_class < StateMachines::InstanceMethods
|
81
|
+
owner_class.class_eval do
|
82
|
+
extend StateMachines::ClassMethods
|
83
|
+
include StateMachines::InstanceMethods
|
84
|
+
end
|
85
|
+
|
86
|
+
define_state_initializer if @initialize_state
|
87
|
+
end
|
88
|
+
|
89
|
+
# Record this machine as matched to the name in the current owner class.
|
90
|
+
# This will override any machines mapped to the same name in any superclasses.
|
91
|
+
owner_class.state_machines[name] = self
|
92
|
+
end
|
93
|
+
|
94
|
+
# Sets the initial state of the machine. This can be either the static name
|
95
|
+
# of a state or a lambda block which determines the initial state at
|
96
|
+
# creation time.
|
97
|
+
def initial_state=(new_initial_state)
|
98
|
+
@initial_state = new_initial_state
|
99
|
+
add_states([@initial_state]) unless dynamic_initial_state?
|
100
|
+
|
101
|
+
# Update all states to reflect the new initial state
|
102
|
+
states.each { |state| state.initial = (state.name == @initial_state) }
|
103
|
+
|
104
|
+
# Output a warning if there are conflicting initial states for the machine's
|
105
|
+
# attribute
|
106
|
+
initial_state = states.detect(&:initial)
|
107
|
+
has_owner_default = !owner_class_attribute_default.nil?
|
108
|
+
has_conflicting_default = dynamic_initial_state? || !owner_class_attribute_default_matches?(initial_state)
|
109
|
+
return unless has_owner_default && has_conflicting_default
|
110
|
+
|
111
|
+
warn(
|
112
|
+
"Both #{owner_class.name} and its #{name.inspect} machine have defined " \
|
113
|
+
"a different default for \"#{attribute}\". Use only one or the other for " \
|
114
|
+
'defining defaults to avoid unexpected behaviors.'
|
115
|
+
)
|
116
|
+
end
|
117
|
+
|
118
|
+
# Gets the attribute name for the given machine scope.
|
119
|
+
def attribute(name = :state)
|
120
|
+
name == :state ? @attribute : :"#{self.name}_#{name}"
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module EventMethods
|
6
|
+
# Defines one or more events for the machine and the transitions that can
|
7
|
+
# be performed when those events are run.
|
8
|
+
def event(*names, &)
|
9
|
+
options = names.last.is_a?(Hash) ? names.pop : {}
|
10
|
+
StateMachines::OptionsValidator.assert_valid_keys!(options, :human_name)
|
11
|
+
|
12
|
+
# Store the context so that it can be used for / matched against any event
|
13
|
+
# that gets added
|
14
|
+
@events.context(names, &) if block_given?
|
15
|
+
|
16
|
+
if names.first.is_a?(Matcher)
|
17
|
+
# Add any events referenced in the matcher. When matchers are used,
|
18
|
+
# events are not allowed to be configured.
|
19
|
+
raise ArgumentError, "Cannot configure events when using matchers (using #{options.inspect})" if options.any?
|
20
|
+
|
21
|
+
events = add_events(names.first.values)
|
22
|
+
else
|
23
|
+
events = add_events(names)
|
24
|
+
|
25
|
+
# Update the configuration for the event(s)
|
26
|
+
events.each do |event|
|
27
|
+
event.human_name = options[:human_name] if options.include?(:human_name)
|
28
|
+
|
29
|
+
# Add any states that may have been referenced within the event
|
30
|
+
add_states(event.known_states)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
events.length == 1 ? events.first : events
|
35
|
+
end
|
36
|
+
|
37
|
+
alias on event
|
38
|
+
|
39
|
+
# Creates a new transition that determines what to change the current state
|
40
|
+
# to when an event fires.
|
41
|
+
def transition(options)
|
42
|
+
raise ArgumentError, 'Must specify :on event' unless options[:on]
|
43
|
+
|
44
|
+
branches = []
|
45
|
+
options = options.dup
|
46
|
+
event(*Array(options.delete(:on))) { branches << transition(options) }
|
47
|
+
|
48
|
+
branches.length == 1 ? branches.first : branches
|
49
|
+
end
|
50
|
+
|
51
|
+
# Gets the list of all possible transition paths from the current state to
|
52
|
+
# the given target state. If multiple target states are provided, then
|
53
|
+
# this will return all possible paths to those states.
|
54
|
+
def paths_for(object, requirements = {})
|
55
|
+
PathCollection.new(object, self, requirements)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module HelperGenerators
|
6
|
+
protected
|
7
|
+
|
8
|
+
# Adds helper methods for interacting with the state machine, including
|
9
|
+
# for states, events, and transitions
|
10
|
+
def define_helpers
|
11
|
+
define_state_accessor
|
12
|
+
define_state_predicate
|
13
|
+
define_event_helpers
|
14
|
+
define_path_helpers
|
15
|
+
define_action_helpers if define_action_helpers?
|
16
|
+
define_name_helpers
|
17
|
+
end
|
18
|
+
|
19
|
+
# Defines the initial values for state machine attributes. Static values
|
20
|
+
# are set prior to the original initialize method and dynamic values are
|
21
|
+
# set *after* the initialize method in case it is dependent on it.
|
22
|
+
def define_state_initializer
|
23
|
+
define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
|
24
|
+
def initialize(*)
|
25
|
+
self.class.state_machines.initialize_states(self) { super }
|
26
|
+
end
|
27
|
+
END_EVAL
|
28
|
+
end
|
29
|
+
|
30
|
+
# Adds reader/writer methods for accessing the state attribute
|
31
|
+
def define_state_accessor
|
32
|
+
attribute = self.attribute
|
33
|
+
|
34
|
+
@helper_modules[:instance].class_eval { attr_reader attribute } unless owner_class_ancestor_has_method?(:instance, attribute)
|
35
|
+
@helper_modules[:instance].class_eval { attr_writer attribute } unless owner_class_ancestor_has_method?(:instance, "#{attribute}=")
|
36
|
+
end
|
37
|
+
|
38
|
+
# Adds predicate method to the owner class for determining the name of the
|
39
|
+
# current state
|
40
|
+
def define_state_predicate
|
41
|
+
call_super = owner_class_ancestor_has_method?(:instance, "#{name}?") ? true : false
|
42
|
+
define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
|
43
|
+
def #{name}?(*args)
|
44
|
+
args.empty? && (#{call_super} || defined?(super)) ? super : self.class.state_machine(#{name.inspect}).states.matches?(self, *args)
|
45
|
+
end
|
46
|
+
END_EVAL
|
47
|
+
end
|
48
|
+
|
49
|
+
# Adds helper methods for getting information about this state machine's
|
50
|
+
# events
|
51
|
+
def define_event_helpers
|
52
|
+
# Gets the events that are allowed to fire on the current object
|
53
|
+
define_helper(:instance, attribute(:events)) do |machine, object, *args|
|
54
|
+
machine.events.valid_for(object, *args).map(&:name)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Gets the next possible transitions that can be run on the current
|
58
|
+
# object
|
59
|
+
define_helper(:instance, attribute(:transitions)) do |machine, object, *args|
|
60
|
+
machine.events.transitions_for(object, *args)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Fire an arbitrary event for this machine
|
64
|
+
define_helper(:instance, "fire_#{attribute(:event)}") do |machine, object, event, *args|
|
65
|
+
machine.events.fetch(event).fire(object, *args)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Add helpers for tracking the event / transition to invoke when the
|
69
|
+
# action is called
|
70
|
+
return unless action
|
71
|
+
|
72
|
+
event_attribute = attribute(:event)
|
73
|
+
define_helper(:instance, event_attribute) do |machine, object|
|
74
|
+
# Interpret non-blank events as present
|
75
|
+
event = machine.read(object, :event, true)
|
76
|
+
event && !(event.respond_to?(:empty?) && event.empty?) ? event.to_sym : nil
|
77
|
+
end
|
78
|
+
|
79
|
+
# A roundabout way of writing the attribute is used here so that
|
80
|
+
# integrations can hook into this modification
|
81
|
+
define_helper(:instance, "#{event_attribute}=") do |machine, object, value|
|
82
|
+
machine.write(object, :event, value, true)
|
83
|
+
end
|
84
|
+
|
85
|
+
event_transition_attribute = attribute(:event_transition)
|
86
|
+
define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
|
87
|
+
protected; attr_accessor #{event_transition_attribute.inspect}
|
88
|
+
END_EVAL
|
89
|
+
end
|
90
|
+
|
91
|
+
# Adds helper methods for getting information about this state machine's
|
92
|
+
# available transition paths
|
93
|
+
def define_path_helpers
|
94
|
+
# Gets the paths of transitions available to the current object
|
95
|
+
define_helper(:instance, attribute(:paths)) do |machine, object, *args|
|
96
|
+
machine.paths_for(object, *args)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Adds helper methods for accessing naming information about states and
|
101
|
+
# events on the owner class
|
102
|
+
def define_name_helpers
|
103
|
+
# Gets the humanized version of a state
|
104
|
+
define_helper(:class, "human_#{attribute(:name)}") do |machine, klass, state|
|
105
|
+
machine.states.fetch(state).human_name(klass)
|
106
|
+
end
|
107
|
+
|
108
|
+
# Gets the humanized version of an event
|
109
|
+
define_helper(:class, "human_#{attribute(:event_name)}") do |machine, klass, event|
|
110
|
+
machine.events.fetch(event).human_name(klass)
|
111
|
+
end
|
112
|
+
|
113
|
+
# Gets the state name for the current value
|
114
|
+
define_helper(:instance, attribute(:name)) do |machine, object|
|
115
|
+
machine.states.match!(object).name
|
116
|
+
end
|
117
|
+
|
118
|
+
# Gets the human state name for the current value
|
119
|
+
define_helper(:instance, "human_#{attribute(:name)}") do |machine, object|
|
120
|
+
machine.states.match!(object).human_name(object.class)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module Integration
|
6
|
+
# Marks the given object as invalid with the given message.
|
7
|
+
#
|
8
|
+
# By default, this is a no-op.
|
9
|
+
def invalidate(_object, _attribute, _message, _values = []); end
|
10
|
+
|
11
|
+
# Gets a description of the errors for the given object. This is used to
|
12
|
+
# provide more detailed information when an InvalidTransition exception is
|
13
|
+
# raised.
|
14
|
+
def errors_for(_object)
|
15
|
+
''
|
16
|
+
end
|
17
|
+
|
18
|
+
# Resets any errors previously added when invalidating the given object.
|
19
|
+
#
|
20
|
+
# By default, this is a no-op.
|
21
|
+
def reset(_object); end
|
22
|
+
|
23
|
+
# Generates a user-friendly name for the given message.
|
24
|
+
def generate_message(name, values = [])
|
25
|
+
format(@messages[name] || @messages[:invalid_transition] || default_messages[name] || default_messages[:invalid_transition], state: values.first)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Runs a transaction, yielding the given block.
|
29
|
+
#
|
30
|
+
# By default, this is a no-op.
|
31
|
+
def within_transaction(object, &)
|
32
|
+
if use_transactions && respond_to?(:transaction, true)
|
33
|
+
transaction(object, &)
|
34
|
+
else
|
35
|
+
yield
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
protected
|
40
|
+
|
41
|
+
# Runs additional initialization hooks. By default, this is a no-op.
|
42
|
+
def after_initialize; end
|
43
|
+
|
44
|
+
# Always yields
|
45
|
+
def transaction(_object)
|
46
|
+
yield
|
47
|
+
end
|
48
|
+
|
49
|
+
# Gets the initial attribute value defined by the owner class (outside of
|
50
|
+
# the machine's definition). By default, this is always nil.
|
51
|
+
def owner_class_attribute_default
|
52
|
+
nil
|
53
|
+
end
|
54
|
+
|
55
|
+
# Checks whether the given state matches the attribute default specified
|
56
|
+
# by the owner class
|
57
|
+
def owner_class_attribute_default_matches?(state)
|
58
|
+
state.matches?(owner_class_attribute_default)
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
# Gets the default messages that can be used in the machine for invalid
|
64
|
+
# transitions.
|
65
|
+
def default_messages
|
66
|
+
{ invalid_transition: '%<state>s cannot transition via "%<event>s"' }
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module Parsing
|
6
|
+
private
|
7
|
+
|
8
|
+
# Parses callback arguments for backward compatibility with both positional
|
9
|
+
# and keyword argument styles. Supports Ruby 3.2+ keyword arguments while
|
10
|
+
# maintaining full backward compatibility with the legacy API.
|
11
|
+
def parse_callback_arguments(args, options)
|
12
|
+
# Handle legacy positional args: before_transition(:method1, :method2, from: :state)
|
13
|
+
if args.any?
|
14
|
+
# Extract hash options from the end of args if present
|
15
|
+
parsed_options = args.last.is_a?(Hash) ? args.pop.dup : {}
|
16
|
+
|
17
|
+
# Merge any additional keyword options
|
18
|
+
parsed_options.merge!(options) if options.any?
|
19
|
+
|
20
|
+
# Remaining args become the :do option (method names to call)
|
21
|
+
parsed_options[:do] = args if args.any?
|
22
|
+
|
23
|
+
parsed_options
|
24
|
+
else
|
25
|
+
# Pure keyword argument style: before_transition(from: :state, to: :other, do: :method)
|
26
|
+
options.dup
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Adds a new transition callback of the given type.
|
31
|
+
def add_callback(type, options, &)
|
32
|
+
callbacks[type == :around ? :before : type] << callback = Callback.new(type, options, &)
|
33
|
+
add_states(callback.known_states)
|
34
|
+
callback
|
35
|
+
end
|
36
|
+
|
37
|
+
# Tracks the given set of states in the list of all known states for
|
38
|
+
# this machine
|
39
|
+
def add_states(new_states)
|
40
|
+
new_states.map do |new_state|
|
41
|
+
# Check for other states that use a different class type for their name.
|
42
|
+
# This typically prevents string / symbol misuse.
|
43
|
+
if new_state && (conflict = states.detect { |state| state.name && state.name.class != new_state.class })
|
44
|
+
raise ArgumentError, "#{new_state.inspect} state defined as #{new_state.class}, #{conflict.name.inspect} defined as #{conflict.name.class}; all states must be consistent"
|
45
|
+
end
|
46
|
+
|
47
|
+
unless (state = states[new_state])
|
48
|
+
states << state = State.new(self, new_state)
|
49
|
+
|
50
|
+
# Copy states over to sibling machines
|
51
|
+
sibling_machines.each { |machine| machine.states << state }
|
52
|
+
end
|
53
|
+
|
54
|
+
state
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Tracks the given set of events in the list of all known events for
|
59
|
+
# this machine
|
60
|
+
def add_events(new_events)
|
61
|
+
new_events.map do |new_event|
|
62
|
+
# Check for other states that use a different class type for their name.
|
63
|
+
# This typically prevents string / symbol misuse.
|
64
|
+
if (conflict = events.detect { |event| event.name.class != new_event.class })
|
65
|
+
raise ArgumentError, "#{new_event.inspect} event defined as #{new_event.class}, #{conflict.name.inspect} defined as #{conflict.name.class}; all events must be consistent"
|
66
|
+
end
|
67
|
+
|
68
|
+
unless (event = events[new_event])
|
69
|
+
events << event = Event.new(self, new_event)
|
70
|
+
end
|
71
|
+
|
72
|
+
event
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module Rendering
|
6
|
+
# Gets the renderer for this machine.
|
7
|
+
def renderer
|
8
|
+
@renderer ||= StdioRenderer.new
|
9
|
+
end
|
10
|
+
|
11
|
+
# Generates a visual representation of this machine for a given format.
|
12
|
+
def draw(**)
|
13
|
+
renderer.draw(self, **)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module Scoping
|
6
|
+
protected
|
7
|
+
|
8
|
+
# Defines the with/without scope helpers for this attribute. Both the
|
9
|
+
# singular and plural versions of the attribute are defined for each
|
10
|
+
# scope helper. A custom plural can be specified if it cannot be
|
11
|
+
# automatically determined by either calling +pluralize+ on the attribute
|
12
|
+
# name or adding an "s" to the end of the name.
|
13
|
+
def define_scopes(custom_plural = nil)
|
14
|
+
plural = custom_plural || pluralize(name)
|
15
|
+
|
16
|
+
%i[with without].each do |kind|
|
17
|
+
[name, plural].map(&:to_s).uniq.each do |suffix|
|
18
|
+
method = "#{kind}_#{suffix}"
|
19
|
+
|
20
|
+
next unless (scope = send("create_#{kind}_scope", method))
|
21
|
+
|
22
|
+
# Converts state names to their corresponding values so that they
|
23
|
+
# can be looked up properly
|
24
|
+
define_helper(:class, method) do |machine, klass, *states|
|
25
|
+
run_scope(scope, machine, klass, states)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Creates a scope for finding objects *with* a particular value or values
|
32
|
+
# for the attribute.
|
33
|
+
#
|
34
|
+
# By default, this is a no-op.
|
35
|
+
def create_with_scope(name); end
|
36
|
+
|
37
|
+
# Creates a scope for finding objects *without* a particular value or
|
38
|
+
# values for the attribute.
|
39
|
+
#
|
40
|
+
# By default, this is a no-op.
|
41
|
+
def create_without_scope(name); end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
class Machine
|
5
|
+
module StateMethods
|
6
|
+
# Gets the initial state of the machine for the given object. If a dynamic
|
7
|
+
# initial state was configured for this machine, then the object will be
|
8
|
+
# passed into the lambda block to help determine the actual state.
|
9
|
+
def initial_state(object)
|
10
|
+
states.fetch(dynamic_initial_state? ? evaluate_method(object, @initial_state) : @initial_state) if instance_variable_defined?(:@initial_state)
|
11
|
+
end
|
12
|
+
|
13
|
+
# Whether a dynamic initial state is being used in the machine
|
14
|
+
def dynamic_initial_state?
|
15
|
+
instance_variable_defined?(:@initial_state) && @initial_state.is_a?(Proc)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Initializes the state on the given object. Initial values are only set if
|
19
|
+
# the machine's attribute hasn't been previously initialized.
|
20
|
+
#
|
21
|
+
# Configuration options:
|
22
|
+
# * <tt>:force</tt> - Whether to initialize the state regardless of its
|
23
|
+
# current value
|
24
|
+
# * <tt>:to</tt> - A hash to set the initial value in instead of writing
|
25
|
+
# directly to the object
|
26
|
+
def initialize_state(object, options = {})
|
27
|
+
state = initial_state(object)
|
28
|
+
return unless state && (options[:force] || initialize_state?(object))
|
29
|
+
|
30
|
+
value = state.value
|
31
|
+
|
32
|
+
if (hash = options[:to])
|
33
|
+
hash[attribute.to_s] = value
|
34
|
+
else
|
35
|
+
write(object, :state, value)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Customizes the definition of one or more states in the machine.
|
40
|
+
def state(*names, &)
|
41
|
+
options = names.last.is_a?(Hash) ? names.pop : {}
|
42
|
+
StateMachines::OptionsValidator.assert_valid_keys!(options, :value, :cache, :if, :human_name)
|
43
|
+
|
44
|
+
# Store the context so that it can be used for / matched against any state
|
45
|
+
# that gets added
|
46
|
+
@states.context(names, &) if block_given?
|
47
|
+
|
48
|
+
if names.first.is_a?(Matcher)
|
49
|
+
# Add any states referenced in the matcher. When matchers are used,
|
50
|
+
# states are not allowed to be configured.
|
51
|
+
raise ArgumentError, "Cannot configure states when using matchers (using #{options.inspect})" if options.any?
|
52
|
+
|
53
|
+
states = add_states(names.first.values)
|
54
|
+
else
|
55
|
+
states = add_states(names)
|
56
|
+
|
57
|
+
# Update the configuration for the state(s)
|
58
|
+
states.each do |state|
|
59
|
+
if options.include?(:value)
|
60
|
+
state.value = options[:value]
|
61
|
+
self.states.update(state)
|
62
|
+
end
|
63
|
+
|
64
|
+
state.human_name = options[:human_name] if options.include?(:human_name)
|
65
|
+
state.cache = options[:cache] if options.include?(:cache)
|
66
|
+
state.matcher = options[:if] if options.include?(:if)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
states.length == 1 ? states.first : states
|
71
|
+
end
|
72
|
+
|
73
|
+
alias other_states state
|
74
|
+
|
75
|
+
# Gets the current value stored in the given object's attribute.
|
76
|
+
def read(object, attribute, ivar = false)
|
77
|
+
attribute = self.attribute(attribute)
|
78
|
+
if ivar
|
79
|
+
object.instance_variable_defined?(:"@#{attribute}") ? object.instance_variable_get("@#{attribute}") : nil
|
80
|
+
else
|
81
|
+
object.send(attribute)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Sets a new value in the given object's attribute.
|
86
|
+
def write(object, attribute, value, ivar = false)
|
87
|
+
attribute = self.attribute(attribute)
|
88
|
+
ivar ? object.instance_variable_set(:"@#{attribute}", value) : object.send("#{attribute}=", value)
|
89
|
+
end
|
90
|
+
|
91
|
+
protected
|
92
|
+
|
93
|
+
# Determines if the machine's attribute needs to be initialized. This
|
94
|
+
# will only be true if the machine's attribute is blank.
|
95
|
+
def initialize_state?(object)
|
96
|
+
value = read(object, :state)
|
97
|
+
(value.nil? || (value.respond_to?(:empty?) && value.empty?)) && !states[value, :value]
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|