state_machines 0.20.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +124 -13
  3. data/lib/state_machines/branch.rb +12 -13
  4. data/lib/state_machines/callback.rb +11 -12
  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 +83 -45
  8. data/lib/state_machines/event.rb +23 -26
  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 +73 -617
  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 +6 -5
  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 +43 -41
  42. data/lib/state_machines/transition_collection.rb +22 -25
  43. data/lib/state_machines/version.rb +1 -1
  44. metadata +23 -9
@@ -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
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ class Machine
5
+ module Utilities
6
+ protected
7
+
8
+ # Looks up other machines that have been defined in the owner class and
9
+ # are targeting the same attribute as this machine. When accessing
10
+ # sibling machines, they will be automatically copied for the current
11
+ # class if they haven't been already. This ensures that any configuration
12
+ # changes made to the sibling machines only affect this class and not any
13
+ # base class that may have originally defined the machine.
14
+ def sibling_machines
15
+ owner_class.state_machines.each_with_object([]) do |(name, machine), machines|
16
+ machines << (owner_class.state_machine(name) {}) if machine.attribute == attribute && machine != self
17
+ end
18
+ end
19
+
20
+ # Looks up the ancestor class that has the given method defined. This
21
+ # is used to find the method owner which is used to determine where to
22
+ # define new methods.
23
+ def owner_class_ancestor_has_method?(scope, method)
24
+ return false unless owner_class_has_method?(scope, method)
25
+
26
+ superclasses = owner_class.ancestors.select { |ancestor| ancestor.is_a?(Class) }[1..]
27
+
28
+ if scope == :class
29
+ current = owner_class.singleton_class
30
+ superclass = superclasses.first
31
+ else
32
+ current = owner_class
33
+ superclass = owner_class.superclass
34
+ end
35
+
36
+ # Generate the list of modules that *only* occur in the owner class, but
37
+ # were included *prior* to the helper modules, in addition to the
38
+ # superclasses
39
+ ancestors = current.ancestors - superclass.ancestors + superclasses
40
+ helper_module_index = ancestors.index(@helper_modules[scope])
41
+ ancestors = helper_module_index ? ancestors[helper_module_index..].reverse : ancestors.reverse
42
+
43
+ # Search for for the first ancestor that defined this method
44
+ ancestors.detect do |ancestor|
45
+ ancestor = ancestor.singleton_class if scope == :class && ancestor.is_a?(Class)
46
+ ancestor.method_defined?(method) || ancestor.private_method_defined?(method)
47
+ end
48
+ end
49
+
50
+ # Determines whether the given method is defined in the owner class or
51
+ # in a superclass.
52
+ def owner_class_has_method?(scope, method)
53
+ target = scope == :class ? owner_class.singleton_class : owner_class
54
+ target.method_defined?(method) || target.private_method_defined?(method)
55
+ end
56
+
57
+ # Pluralizes the given word using #pluralize (if available) or simply
58
+ # adding an "s" to the end of the word
59
+ def pluralize(word)
60
+ word = word.to_s
61
+ if word.respond_to?(:pluralize)
62
+ word.pluralize
63
+ else
64
+ "#{word}s"
65
+ end
66
+ end
67
+
68
+ # Generates the results for the given scope based on one or more states to
69
+ # filter by
70
+ def run_scope(scope, machine, klass, states)
71
+ values = states.flatten.compact.map { |state| machine.states.fetch(state).value }
72
+ scope.call(klass, values)
73
+ end
74
+
75
+ # Adds sibling machine configurations to the current machine. This
76
+ # will add states from other machines that have the same attribute.
77
+ def add_sibling_machine_configs
78
+ # Add existing states
79
+ sibling_machines.each do |machine|
80
+ machine.states.each { |state| states << state unless states[state.name] }
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ class Machine
5
+ module Validation
6
+ # Frozen constant to avoid repeated array allocations
7
+ DANGEROUS_PATTERNS = [
8
+ /`.*`/, # Backticks (shell execution)
9
+ /system\s*\(/, # System calls
10
+ /exec\s*\(/, # Exec calls
11
+ /eval\s*\(/, # Nested eval
12
+ /require\s+['"]/, # Require statements
13
+ /load\s+['"]/, # Load statements
14
+ /File\./, # File operations
15
+ /IO\./, # IO operations
16
+ /Dir\./, # Directory operations
17
+ /Kernel\./ # Kernel operations
18
+ ].freeze
19
+
20
+ private
21
+
22
+ # Validates string input before eval to prevent code injection
23
+ # This is a basic safety check - not foolproof security
24
+ def validate_eval_string(method_string)
25
+ # Check for obviously dangerous patterns
26
+ DANGEROUS_PATTERNS.each do |pattern|
27
+ raise SecurityError, "Potentially dangerous code detected in eval string: #{method_string.inspect}" if method_string.match?(pattern)
28
+ end
29
+
30
+ # Basic syntax validation (cross-platform)
31
+ begin
32
+ SyntaxValidator.validate!(method_string, '(eval)')
33
+ rescue SyntaxError => e
34
+ raise ArgumentError, "Invalid Ruby syntax in eval string: #{e.message}"
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end