state_gate 1.2.3
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 +7 -0
- data/lib/state_gate/builder/conflict_detection_methods.rb +134 -0
- data/lib/state_gate/builder/dynamic_module_creation_methods.rb +120 -0
- data/lib/state_gate/builder/scope_methods.rb +96 -0
- data/lib/state_gate/builder/state_methods.rb +289 -0
- data/lib/state_gate/builder/transition_methods.rb +142 -0
- data/lib/state_gate/builder/transition_validation_methods.rb +247 -0
- data/lib/state_gate/builder.rb +244 -0
- data/lib/state_gate/engine/configurator.rb +230 -0
- data/lib/state_gate/engine/errator.rb +63 -0
- data/lib/state_gate/engine/fixer.rb +75 -0
- data/lib/state_gate/engine/scoper.rb +66 -0
- data/lib/state_gate/engine/sequencer.rb +116 -0
- data/lib/state_gate/engine/stator.rb +225 -0
- data/lib/state_gate/engine/transitioner.rb +73 -0
- data/lib/state_gate/engine.rb +65 -0
- data/lib/state_gate/locale/builder_en.yml +14 -0
- data/lib/state_gate/locale/engine_en.yml +58 -0
- data/lib/state_gate/locale/state_gate_en.yml +5 -0
- data/lib/state_gate/rspec/allow_transitions_on.rb +259 -0
- data/lib/state_gate/rspec/have_states.rb +140 -0
- data/lib/state_gate/rspec.rb +6 -0
- data/lib/state_gate/type.rb +112 -0
- data/lib/state_gate.rb +193 -0
- metadata +83 -0
@@ -0,0 +1,230 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateGate
|
4
|
+
class Engine
|
5
|
+
##
|
6
|
+
# = Description
|
7
|
+
#
|
8
|
+
# Parses the configuration for StateGate::Engine.
|
9
|
+
#
|
10
|
+
# The configuration defines the state names, allowed transitions and a number of
|
11
|
+
# options to help customise the _state-gate_ to your exact preference.
|
12
|
+
#
|
13
|
+
# Options include:
|
14
|
+
#
|
15
|
+
# === | state
|
16
|
+
# Required name for the new state, supplied as a Symbol. The +state-gate+ requires
|
17
|
+
# a minimum of two states to be defined.
|
18
|
+
# state :state_name
|
19
|
+
#
|
20
|
+
#
|
21
|
+
# [:transitions_to]
|
22
|
+
# An optional list of the other state that this state is allowed to change to.
|
23
|
+
# state :state_1, transtions_to: [:state_2, :state_3, :state_4]
|
24
|
+
# state :state_2, transtions_to: :state_4
|
25
|
+
# state :state_3, transtions_to: :any
|
26
|
+
# state :state_4
|
27
|
+
#
|
28
|
+
# [:human]
|
29
|
+
# An optional String name to used when displaying gthe state in a view. If no
|
30
|
+
# name is specified, it will default to +:state.titleized+.
|
31
|
+
# state :state_1, transtions_to: [:state_2, :state_3], human: "My State"
|
32
|
+
#
|
33
|
+
#
|
34
|
+
# === | default
|
35
|
+
# Optional setting to specify the default state for a new object. The state name
|
36
|
+
# is given as a Symbol.
|
37
|
+
# default :state_name
|
38
|
+
#
|
39
|
+
#
|
40
|
+
# === | prefix
|
41
|
+
# Optional setting to add a given Symbol before each state name when using Class Scopes.
|
42
|
+
# This helps to differential between multiple attributes that have similar state names.
|
43
|
+
# prefix :before # => Class.before_active
|
44
|
+
#
|
45
|
+
#
|
46
|
+
# === | suffix
|
47
|
+
# Optional setting to add a given Symbol after each state name when using Class Scopes.
|
48
|
+
# This helps to differential between multiple attributes that have similar state names.
|
49
|
+
# suffix :after # => Class.active_after
|
50
|
+
#
|
51
|
+
#
|
52
|
+
# === | make_sequential
|
53
|
+
# Optional setting to automatically add transitions from each state to both the
|
54
|
+
# preceeding and following states.
|
55
|
+
# make_sequential
|
56
|
+
#
|
57
|
+
# [:one_way]
|
58
|
+
# Option to restrict the generated transitions to one directtion only: from each
|
59
|
+
# state to the follow state.
|
60
|
+
# make_sequential :one_way
|
61
|
+
#
|
62
|
+
# [:loop]
|
63
|
+
# Option to add transitions from the last state to the first and, unless +:one_way+
|
64
|
+
# is specified, also from the first state to the last.
|
65
|
+
# make_sequential :one_way, :loop
|
66
|
+
#
|
67
|
+
#
|
68
|
+
# === | no_scopes
|
69
|
+
# Optional setting to disable the generation of Class Scope helpers methods.
|
70
|
+
# no_scopes
|
71
|
+
#
|
72
|
+
module Configurator
|
73
|
+
|
74
|
+
# = Private
|
75
|
+
# ======================================================================
|
76
|
+
private
|
77
|
+
|
78
|
+
|
79
|
+
# ======================================================================
|
80
|
+
# Configuration Commands
|
81
|
+
# ======================================================================
|
82
|
+
|
83
|
+
|
84
|
+
# Execute the provided configuration.
|
85
|
+
#
|
86
|
+
# ==== actions
|
87
|
+
#
|
88
|
+
# + create sequence links and transitions
|
89
|
+
# + create scope names
|
90
|
+
# + remove duplicate transitions for each state
|
91
|
+
#
|
92
|
+
# + verify there are multiple valid state names
|
93
|
+
# + verify all transitions lead to existing states
|
94
|
+
# + verify each state, except the default, can be reached from a transition
|
95
|
+
#
|
96
|
+
def parse_configuration(&config)
|
97
|
+
exec_configuration(&config)
|
98
|
+
|
99
|
+
generate_sequences
|
100
|
+
generate_scope_names
|
101
|
+
|
102
|
+
assert_states_are_valid
|
103
|
+
assert_transitions_exist
|
104
|
+
assert_uniq_transitions
|
105
|
+
assert_any_has_been_expanded
|
106
|
+
assert_all_transitions_are_states
|
107
|
+
assert_all_states_are_reachable
|
108
|
+
end
|
109
|
+
|
110
|
+
|
111
|
+
|
112
|
+
# Run the configuration commands.
|
113
|
+
#
|
114
|
+
# ==== actions
|
115
|
+
#
|
116
|
+
# + create sequence links and transitions
|
117
|
+
# + create scope names
|
118
|
+
# + remove duplicate transitions for each state
|
119
|
+
#
|
120
|
+
# + verify there are multiple valid state names
|
121
|
+
# + verify all transitions lead to existing states
|
122
|
+
# + verify each state, except the default, can be reached from a transition
|
123
|
+
#
|
124
|
+
def exec_configuration(&config)
|
125
|
+
instance_exec(&config)
|
126
|
+
rescue NameError => e
|
127
|
+
err_command = e.to_s.gsub('undefined local variable or method `', '')
|
128
|
+
.split("'")
|
129
|
+
.first
|
130
|
+
cerr :bad_command, cmd: err_command
|
131
|
+
end
|
132
|
+
|
133
|
+
|
134
|
+
|
135
|
+
# ======================================================================
|
136
|
+
# Assertions
|
137
|
+
# ======================================================================
|
138
|
+
|
139
|
+
# Ensure there are enough states and the default is a valid state, setting
|
140
|
+
# the default to the first state if required.
|
141
|
+
#
|
142
|
+
def assert_states_are_valid
|
143
|
+
state_names = @states.keys
|
144
|
+
|
145
|
+
# are there states
|
146
|
+
cerr(:states_missing_err) if state_names.blank?
|
147
|
+
|
148
|
+
# is there more than one state
|
149
|
+
cerr(:single_state_err) if state_names.one?
|
150
|
+
|
151
|
+
# set the deafult state if needed, otherwise check it is a valid state
|
152
|
+
if @default
|
153
|
+
cerr(:default_state_err) unless state_names.include?(@default)
|
154
|
+
else
|
155
|
+
@default = state_names.first
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
|
160
|
+
|
161
|
+
# Ensure that transitions have been specified. If not, then add the transitions
|
162
|
+
# to allow every stater to transition to another state and flag the engine as
|
163
|
+
# transitionless, so we don't add any validation methods.
|
164
|
+
#
|
165
|
+
def assert_transitions_exist
|
166
|
+
return if @states.map { |_state, opts| opts[:transitions_to] }.uniq.flatten.any?
|
167
|
+
|
168
|
+
@transitionless = true
|
169
|
+
@states.keys.each do |key|
|
170
|
+
@states[key][:transitions_to] = @states.keys - [key]
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
|
175
|
+
|
176
|
+
# Ensure that there is only one of reach transition
|
177
|
+
#
|
178
|
+
def assert_uniq_transitions
|
179
|
+
@states.each { |_state, opts| opts[:transitions_to].uniq! }
|
180
|
+
end
|
181
|
+
|
182
|
+
|
183
|
+
|
184
|
+
# Ensure that the :any transition is expanded or raise an exception
|
185
|
+
# if it's included with other transitions
|
186
|
+
def assert_any_has_been_expanded
|
187
|
+
@states.each do |state_name, opts|
|
188
|
+
if opts[:transitions_to] == [:any]
|
189
|
+
@states[state_name][:transitions_to] = @states.keys - [state_name]
|
190
|
+
|
191
|
+
elsif opts[:transitions_to].include?(:any)
|
192
|
+
cerr(:any_transition_err, state: state_name, kattr: true)
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
|
198
|
+
|
199
|
+
# Ensure all transitions are to valid states.
|
200
|
+
#
|
201
|
+
# Replaces transition to :any with a list of all states
|
202
|
+
# Raises an exception if :any in included with a list of other transitions
|
203
|
+
#
|
204
|
+
def assert_all_transitions_are_states
|
205
|
+
@states.each do |state_name, opts|
|
206
|
+
opts[:transitions_to].each do |transition|
|
207
|
+
unless @states.keys.include?(transition)
|
208
|
+
cerr(:transition_state_err, state: state_name, transition: transition, kattr: true)
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
|
215
|
+
|
216
|
+
# Ensure there is a transition leading to every non-default state.
|
217
|
+
#
|
218
|
+
def assert_all_states_are_reachable
|
219
|
+
# is there a transition to every state except the default.
|
220
|
+
transitions = @states.map { |_state, opts| opts[:transitions_to] }.flatten.uniq
|
221
|
+
adrift_states = (@states.keys - transitions - [@default])
|
222
|
+
return if adrift_states.blank?
|
223
|
+
|
224
|
+
states = adrift_states.map { |s| ':' + s.to_s }.to_sentence
|
225
|
+
cerr(:transitionless_states_err, states: states, kattr: true)
|
226
|
+
end
|
227
|
+
|
228
|
+
end # ConfigurationMethods
|
229
|
+
end # Engine
|
230
|
+
end # StateGate
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateGate
|
4
|
+
class Engine
|
5
|
+
##
|
6
|
+
# = Description
|
7
|
+
#
|
8
|
+
# Adds error reporting methods to a StateMachine::Engine
|
9
|
+
# * all error messages are I18n configured
|
10
|
+
# * method names are deliberately short to encourage code readability with
|
11
|
+
# if/unless one-liners
|
12
|
+
module Errator
|
13
|
+
|
14
|
+
# Private
|
15
|
+
# ======================================================================
|
16
|
+
private
|
17
|
+
|
18
|
+
# Format the given value and report an ArgumentError
|
19
|
+
#
|
20
|
+
def invalid_state_error(val)
|
21
|
+
case val
|
22
|
+
when NilClass
|
23
|
+
aerr :invalid_state_err, val: "'nil'", kattr: true
|
24
|
+
when Symbol
|
25
|
+
aerr :invalid_state_err, val: ":#{val}", kattr: true
|
26
|
+
else
|
27
|
+
aerr :invalid_state_err, val: "'#{val&.to_s}'", kattr: true
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
|
33
|
+
# Report a ConfigurationError, including the Klass#attr variable.
|
34
|
+
#
|
35
|
+
def cerr(err, **args)
|
36
|
+
args[:kattr] = "#{@klass}##{@attribute}" if args[:kattr] == true
|
37
|
+
key = "state_gate.engine.config.#{err}"
|
38
|
+
fail ConfigurationError, I18n.t(key, **args)
|
39
|
+
end
|
40
|
+
|
41
|
+
|
42
|
+
|
43
|
+
# Report a RuntimeError, including the Klass#attr variable.
|
44
|
+
#
|
45
|
+
def rerr(err, **args)
|
46
|
+
args[:kattr] = "#{@klass}##{@attribute}" if args[:kattr] == true
|
47
|
+
key = "state_gate.engine.#{err}"
|
48
|
+
fail I18n.t(key, **args)
|
49
|
+
end
|
50
|
+
|
51
|
+
|
52
|
+
|
53
|
+
# Report an ArgumentError, including the Klass#attr variable.
|
54
|
+
#
|
55
|
+
def aerr(err, **args)
|
56
|
+
args[:kattr] = "#{@klass}##{@attribute}" if args[:kattr] == true
|
57
|
+
key = "state_gate.engine.#{err}"
|
58
|
+
fail ArgumentError, I18n.t(key, **args)
|
59
|
+
end
|
60
|
+
|
61
|
+
end # Errator
|
62
|
+
end # Engine
|
63
|
+
end # StateGate
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateGate
|
4
|
+
class Engine
|
5
|
+
##
|
6
|
+
# = Description
|
7
|
+
#
|
8
|
+
# Provides prefix and suffix helper methods for StateGate::Engine.
|
9
|
+
#
|
10
|
+
module Fixer
|
11
|
+
|
12
|
+
# ======================================================================
|
13
|
+
# Configuration Methods
|
14
|
+
# ======================================================================
|
15
|
+
|
16
|
+
# A phrase to add before state names when using Class Scopes.
|
17
|
+
# This helps differential attributes that have similar state names.
|
18
|
+
# (Symbol | optional)
|
19
|
+
#
|
20
|
+
# prefix :before # => Class.before_active
|
21
|
+
#
|
22
|
+
def prefix(val = nil)
|
23
|
+
cerr(:prefix_type_err, kattr: true) unless val.is_a?(Symbol)
|
24
|
+
cerr(:prefix_multiple_err, kattr: true) if @prefix
|
25
|
+
@prefix = "#{val.to_s.downcase}_"
|
26
|
+
end # prefix
|
27
|
+
|
28
|
+
|
29
|
+
|
30
|
+
# A phrase to add before state names when using Class Scopes.
|
31
|
+
# This helps differential attributes that have similar state names.
|
32
|
+
# (Symbol | optional)
|
33
|
+
#
|
34
|
+
# suffix :after # => Class.active_after
|
35
|
+
#
|
36
|
+
def suffix(val = nil)
|
37
|
+
cerr(:suffix_type_err, kattr: true) unless val.is_a?(Symbol)
|
38
|
+
cerr(:suffix_multiple_err, kattr: true) if @suffix
|
39
|
+
@suffix = "_#{val.to_s.downcase}"
|
40
|
+
end # suffix
|
41
|
+
|
42
|
+
|
43
|
+
|
44
|
+
# ======================================================================
|
45
|
+
# Helper Methods
|
46
|
+
# ======================================================================
|
47
|
+
|
48
|
+
##
|
49
|
+
# Returns the defined prefix for the state_gate, or an empty string if no
|
50
|
+
# prefix has been defined.
|
51
|
+
#
|
52
|
+
# .state_prefix # => 'my_prefix'
|
53
|
+
# .state_prefix # => ''
|
54
|
+
#
|
55
|
+
def state_prefix
|
56
|
+
@prefix
|
57
|
+
end
|
58
|
+
|
59
|
+
|
60
|
+
|
61
|
+
##
|
62
|
+
# Returns the defined suffix for the state_gate, or an empty string if no
|
63
|
+
# suffix has been defined.
|
64
|
+
#
|
65
|
+
# .state_suffix # => 'my_suffix'
|
66
|
+
# .state_suffix # => ''
|
67
|
+
#
|
68
|
+
def state_suffix
|
69
|
+
@suffix
|
70
|
+
end
|
71
|
+
|
72
|
+
|
73
|
+
end # Sequencer
|
74
|
+
end # Engine
|
75
|
+
end # StateGate
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateGate
|
4
|
+
class Engine
|
5
|
+
##
|
6
|
+
# = Description
|
7
|
+
#
|
8
|
+
# Provides scope helper methods for StateGate::Engine.
|
9
|
+
#
|
10
|
+
module Scoper
|
11
|
+
|
12
|
+
# ======================================================================
|
13
|
+
# Configuration Methods
|
14
|
+
# ======================================================================
|
15
|
+
|
16
|
+
# Disables the generation of Class Scope helpers methods
|
17
|
+
#
|
18
|
+
# no_scopes
|
19
|
+
#
|
20
|
+
def no_scopes
|
21
|
+
@scopes = false
|
22
|
+
end
|
23
|
+
|
24
|
+
|
25
|
+
|
26
|
+
# Generate the scope name to use for each state.
|
27
|
+
#
|
28
|
+
def generate_scope_names
|
29
|
+
@states.each do |state, opts|
|
30
|
+
opts[:scope_name] = "#{@prefix}#{state}#{@suffix}"
|
31
|
+
end # each state
|
32
|
+
end # generate_sequences
|
33
|
+
|
34
|
+
|
35
|
+
|
36
|
+
# ======================================================================
|
37
|
+
# Helper Methods
|
38
|
+
# ======================================================================
|
39
|
+
|
40
|
+
##
|
41
|
+
# Returns TRUE if scope methods should be added to the model, otherwise FALSE.
|
42
|
+
#
|
43
|
+
# .include_scopes? # => true
|
44
|
+
#
|
45
|
+
def include_scopes?
|
46
|
+
!!@scopes
|
47
|
+
end
|
48
|
+
|
49
|
+
|
50
|
+
|
51
|
+
##
|
52
|
+
# Returns the scope name for the given state. Scope names are generated by
|
53
|
+
# concatenating the prefix, state name and suffix
|
54
|
+
#
|
55
|
+
# .scope_name_for_state(:active) # => 'active'
|
56
|
+
# .scope_name_for_state(:pending) # => 'pending_status'
|
57
|
+
# .scope_name_for_state(:archived) # => 'with_archived_status'
|
58
|
+
#
|
59
|
+
def scope_name_for_state(state_name = nil)
|
60
|
+
state = assert_valid_state!(state_name)
|
61
|
+
@states[state][:scope_name]
|
62
|
+
end
|
63
|
+
|
64
|
+
end # Sequencer
|
65
|
+
end # Engine
|
66
|
+
end # StateGate
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateGate
|
4
|
+
class Engine
|
5
|
+
##
|
6
|
+
# = Description
|
7
|
+
#
|
8
|
+
# Provides state sequence helper methods for StateGate::Engine.
|
9
|
+
#
|
10
|
+
# All methods raise an error if +sequential?+ is FALSE
|
11
|
+
#
|
12
|
+
module Sequencer
|
13
|
+
|
14
|
+
# ======================================================================
|
15
|
+
# Configuration Methods
|
16
|
+
# ======================================================================
|
17
|
+
|
18
|
+
# Automatically add transitions from each state to the preceeding and following states.
|
19
|
+
# make_sequential
|
20
|
+
#
|
21
|
+
# [:one_way]
|
22
|
+
# Only adds tranitions from each state to the follow state. (optional)
|
23
|
+
# make_sequential :one_way
|
24
|
+
#
|
25
|
+
# [:loop]
|
26
|
+
# Adds transitions from the last state to the first and from the first to the last
|
27
|
+
# (unless also :one_way) (optional)
|
28
|
+
# make_sequential :one_way, :loop
|
29
|
+
#
|
30
|
+
def make_sequential(*args)
|
31
|
+
@sequential = true
|
32
|
+
@sequential_loop = true if args.include?(:loop)
|
33
|
+
@sequential_one_way = true if args.include?(:one_way)
|
34
|
+
end
|
35
|
+
|
36
|
+
|
37
|
+
|
38
|
+
# Add sequence hooks if sequential requested.
|
39
|
+
#
|
40
|
+
def generate_sequences
|
41
|
+
return unless sequential?
|
42
|
+
|
43
|
+
add_previous_sequential_state
|
44
|
+
add_next_sequential_state
|
45
|
+
loop_sequence
|
46
|
+
end # generate_sequences
|
47
|
+
|
48
|
+
|
49
|
+
|
50
|
+
# Add the previous sequential state
|
51
|
+
#
|
52
|
+
def add_previous_sequential_state
|
53
|
+
return if @sequential_one_way
|
54
|
+
|
55
|
+
previous_state = nil
|
56
|
+
@states.keys.each do |state|
|
57
|
+
if previous_state
|
58
|
+
@states[state][:previous_state] = previous_state
|
59
|
+
@states[state][:transitions_to] << previous_state
|
60
|
+
end
|
61
|
+
previous_state = state
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
|
66
|
+
|
67
|
+
# Add the next sequential state
|
68
|
+
#
|
69
|
+
def add_next_sequential_state
|
70
|
+
next_state = nil
|
71
|
+
@states.keys.reverse.each do |state|
|
72
|
+
if next_state
|
73
|
+
@states[state][:next_state] = next_state
|
74
|
+
@states[state][:transitions_to] << next_state
|
75
|
+
end
|
76
|
+
next_state = state
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
|
81
|
+
|
82
|
+
# Add the first and last transitions to complete the sequential loop.
|
83
|
+
#
|
84
|
+
def loop_sequence
|
85
|
+
return unless @sequential_loop
|
86
|
+
|
87
|
+
first_state = @states.keys.first
|
88
|
+
last_state = @states.keys.last
|
89
|
+
|
90
|
+
@states[last_state][:next_state] = first_state
|
91
|
+
@states[last_state][:transitions_to] << first_state
|
92
|
+
|
93
|
+
return if @sequential_one_way
|
94
|
+
|
95
|
+
@states[first_state][:previous_state] = last_state
|
96
|
+
@states[first_state][:transitions_to] << last_state
|
97
|
+
end # loop_sequence
|
98
|
+
|
99
|
+
|
100
|
+
|
101
|
+
# ======================================================================
|
102
|
+
# Helper Methods
|
103
|
+
# ======================================================================
|
104
|
+
|
105
|
+
##
|
106
|
+
# return TRUE if the state_gate is sequential, otherwise FALSE.
|
107
|
+
#
|
108
|
+
# .sequential? # => TRUE
|
109
|
+
#
|
110
|
+
def sequential?
|
111
|
+
!!@sequential
|
112
|
+
end
|
113
|
+
|
114
|
+
end # Sequencer
|
115
|
+
end # Engine
|
116
|
+
end # StateGate
|