state_machines 0.20.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +154 -18
- data/lib/state_machines/branch.rb +30 -17
- data/lib/state_machines/callback.rb +12 -13
- data/lib/state_machines/core.rb +0 -1
- data/lib/state_machines/error.rb +5 -4
- data/lib/state_machines/eval_helpers.rb +178 -49
- data/lib/state_machines/event.rb +31 -32
- data/lib/state_machines/event_collection.rb +4 -5
- data/lib/state_machines/extensions.rb +5 -5
- data/lib/state_machines/helper_module.rb +1 -1
- data/lib/state_machines/integrations/base.rb +1 -1
- data/lib/state_machines/integrations.rb +11 -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 +25 -11
- 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 +75 -619
- data/lib/state_machines/machine_collection.rb +18 -14
- data/lib/state_machines/macro_methods.rb +2 -2
- data/lib/state_machines/matcher.rb +6 -6
- data/lib/state_machines/matcher_helpers.rb +1 -1
- data/lib/state_machines/node_collection.rb +18 -17
- data/lib/state_machines/path.rb +2 -4
- data/lib/state_machines/path_collection.rb +2 -3
- data/lib/state_machines/state.rb +14 -7
- data/lib/state_machines/state_collection.rb +3 -3
- data/lib/state_machines/state_context.rb +6 -7
- data/lib/state_machines/stdio_renderer.rb +16 -16
- data/lib/state_machines/syntax_validator.rb +57 -0
- data/lib/state_machines/test_helper.rb +290 -27
- data/lib/state_machines/transition.rb +57 -46
- data/lib/state_machines/transition_collection.rb +22 -25
- data/lib/state_machines/version.rb +1 -1
- metadata +23 -9
@@ -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
|