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.
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateGate
4
+ class Engine
5
+ ##
6
+ # = Description
7
+ #
8
+ # Provides state helper methods for StateGate::Engine.
9
+ #
10
+ module Stator
11
+
12
+ # ======================================================================
13
+ # Configursation Methods
14
+ # ======================================================================
15
+
16
+ # Add a new +state+.
17
+ #
18
+ # [:state_name*]
19
+ # The name for the new state. (Symbol | required)
20
+ # state :state_name
21
+ #
22
+ #
23
+ # [:transitions_to]
24
+ # A list of other state that this state may change to. (Array | optional)
25
+ # state :state_name, transtions_to: [:state_1, :state_4, :state_5]
26
+ #
27
+ # [:human]
28
+ # A display name for the state used in views.
29
+ # (String | optoinal | default: state.titleized)
30
+ # state :state_name, transtions_to: [:state_1, :state_4, :state_5], human: "My State"
31
+ #
32
+ def state(name, opts = {})
33
+ name = StateGate.symbolize(name)
34
+ assert_valid_state_name(name)
35
+ assert_valid_opts(opts, name)
36
+
37
+ # add the state
38
+ @states[name] = state_template.merge({ human: opts[:human] || name.to_s.titleize })
39
+
40
+ # add the state transitions
41
+ Array(opts[:transitions_to]).each do |transition|
42
+ @states[name][:transitions_to] << StateGate.symbolize(transition)
43
+ end
44
+ end # state
45
+
46
+
47
+
48
+ # The name for the default state for a new object. (String | required)
49
+ #
50
+ # default :state_name
51
+ #
52
+ def default(val = nil)
53
+ cerr(:default_type_err, kattr: true) unless val.is_a?(Symbol)
54
+ cerr(:default_repeat_err, kattr: true) if @default
55
+ @default = StateGate.symbolize(val)
56
+ end
57
+
58
+
59
+
60
+ # ======================================================================
61
+ # Helper Methods
62
+ # ======================================================================
63
+
64
+ ##
65
+ # Returns an Array defined states.
66
+ #
67
+ # .states # => [:pending, :active, :suspended, :archived]
68
+ #
69
+ def states
70
+ @states.keys
71
+ end
72
+
73
+
74
+
75
+ ##
76
+ # Ensures the given value is a valid state name.
77
+ #
78
+ # [value]
79
+ # A String or Symbol state name.
80
+ #
81
+ # .assert_valid_state!(:active) # => :active
82
+ # .assert_valid_state!('PENDING') # => :pending
83
+ # .assert_valid_state!(:dummy) # => ArgumentError
84
+ #
85
+ #
86
+ # [Note]
87
+ # Valid state names preceeded with +force_+ are also allowed.
88
+ #
89
+ # .assert_valid_state!(:force_active) # => :force_active
90
+ #
91
+ # Returns the Symbol state name
92
+ # Raises an exception if the value is not a valid state name.
93
+ #
94
+ def assert_valid_state!(value)
95
+ state_name = StateGate.symbolize(value)
96
+ unforced_state = state_name.to_s.remove(/^force_/).to_sym
97
+ invalid_state_error(value) unless @states.keys.include?(unforced_state)
98
+ state_name
99
+ end
100
+
101
+
102
+
103
+ ##
104
+ # Returns an Array of the human display names for each defined state.
105
+ #
106
+ # human_states # => ['Pending Activation', 'Active', 'Suspended By Admin']
107
+ #
108
+ def human_states
109
+ @states.map { |_k, v| v[:human] }
110
+ end
111
+
112
+
113
+
114
+ ##
115
+ # Returns the human display name for a given state.
116
+ #
117
+ # .human_state_for(:pending) # => 'Panding Activation'
118
+ # .human_state_for(:active) # => 'Active'
119
+ #
120
+ def human_state_for(state)
121
+ state_name = assert_valid_state!(state)
122
+ @states[state_name][:human]
123
+ end
124
+
125
+
126
+
127
+ ##
128
+ # Return an Array of states, with their human display names, ready for
129
+ # use in a form select.
130
+ #
131
+ # sorted - TRUE is the states should be sorted by human name, defaults to false
132
+ #
133
+ # .states_for_select # => [['Pending Activation', 'pending'],
134
+ # # ['Active', 'active'],
135
+ # # ['Suspended by Admin', 'suspended']]
136
+ #
137
+ # .states_for_select(true) # => [['Active', 'active'],
138
+ # # ['Pending Activation', 'pending'],
139
+ # # ['Suspended by Admin', 'suspended']]
140
+ #
141
+ def states_for_select(sorted = false)
142
+ result = []
143
+ if sorted
144
+ @states.sort_by { |_k, v| v[:human] }
145
+ .each { |state, opts| result << [opts[:human], state.to_s] }
146
+ else
147
+ @states.each { |state, opts| result << [opts[:human], state.to_s] }
148
+ end
149
+ result
150
+ end
151
+
152
+
153
+
154
+ ##
155
+ # Return the raw states hash, allowing inspection of the core engine states.
156
+ #
157
+ # .raw_states # => { pending: { transitions_to: [:active],
158
+ # # human: 'Pending Activation'},
159
+ # # active: { transitions_to: [:pending],
160
+ # # human: 'Active'}}
161
+ #
162
+ def raw_states
163
+ @states
164
+ end
165
+
166
+
167
+
168
+ ##
169
+ # Returns the state_gate default state
170
+ #
171
+ # .default_state # => :pending
172
+ #
173
+ def default_state
174
+ @default
175
+ end
176
+
177
+
178
+
179
+ # ======================================================================
180
+ # Private
181
+ # ======================================================================
182
+ private
183
+
184
+
185
+
186
+ # return a Hash with the state keys defined.
187
+ def state_template
188
+ {
189
+ transitions_to: [],
190
+ previous_state: nil,
191
+ next_state: nil,
192
+ scope_name: nil,
193
+ human: ''
194
+ }
195
+ end
196
+
197
+
198
+
199
+ # fail if the supplied name is not a symbol, already added
200
+ # or starts with 'not_' or 'force'.
201
+ #
202
+ def assert_valid_state_name(name)
203
+ cerr(:state_type_err, kattr: true) unless name.is_a?(Symbol)
204
+ cerr(:state_repeat_err, state: name, kattr: true) if @states.key?(name)
205
+ cerr(:state_not_name_err, state: name, kattr: true) if name.to_s.start_with?('not_')
206
+ cerr(:state_force_name_err, state: name, kattr: true) if name.to_s.start_with?('force_')
207
+ end
208
+
209
+
210
+
211
+ # Fail if opts is not a hash, has non-symbol keys or non-symbol transitions.
212
+ #
213
+ def assert_valid_opts(opts, state_name)
214
+ unless opts.keys.reject { |k| k.is_a?(Symbol) }.blank?
215
+ cerr(:state_opts_key_type_err, state: state_name, kattr: true)
216
+ end
217
+
218
+ return if Array(opts[:transitions_to]).reject { |k| k.is_a?(Symbol) }.blank?
219
+
220
+ cerr(:transition_key_type_err, state: state_name, kattr: true)
221
+ end
222
+
223
+ end # Stater
224
+ end # Engine
225
+ end # StateGate
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateGate
4
+ class Engine
5
+ ##
6
+ # = Description
7
+ #
8
+ # Provides transition helper methods for StateGate::Engine.
9
+ #
10
+ module Transitioner
11
+
12
+ ##
13
+ # Returns TRUE if every state can transition to every other state, rendering
14
+ # transitions pointless.
15
+ #
16
+ # .transitionless? # => true
17
+ #
18
+ def transitionless?
19
+ !!@transitionless
20
+ end
21
+
22
+
23
+
24
+ ##
25
+ # Returns a Hash of states and allowed transitions.
26
+ #
27
+ # .transitions # => { pending: [:active],
28
+ # # ativive: [:suspended, :archived],
29
+ # # suspended: [:active, :archived],
30
+ # # archived: [] }
31
+ #
32
+ def transitions
33
+ @transitions ||= begin
34
+ transitions = {}
35
+ @states.each { |k, v| transitions[k] = v[:transitions_to] } # rubocop:disable Layout/ExtraSpacing
36
+ transitions
37
+ end
38
+ end
39
+
40
+
41
+
42
+ ##
43
+ # Return an Array of allowed transitions for the given state
44
+ #
45
+ # .transitions_for_state(:active) # => [:suspended, :archived]
46
+ #
47
+ def transitions_for_state(state)
48
+ state_name = assert_valid_state!(state)
49
+ transitions[state_name]
50
+ end
51
+
52
+
53
+
54
+ ##
55
+ # Returns TRUE if a transition is allowed, otherwise raises an exception.
56
+ #
57
+ # .assert_valid_transition!(:pending, :active) # => true
58
+ # .assert_valid_transition!(:active, :pending) # => ArgumentError
59
+ #
60
+ def assert_valid_transition!(current_state = nil, new_state = nil)
61
+ from_state = assert_valid_state!(current_state)
62
+ to_state = assert_valid_state!(new_state)
63
+
64
+ return true if to_state == from_state
65
+ return true if to_state.to_s.start_with?('force_')
66
+ return true if @states[from_state][:transitions_to].include?(to_state)
67
+
68
+ aerr(:invalid_state_transition_err, from: from_state, to: to_state, kattr: true)
69
+ end
70
+
71
+ end # Sequencer
72
+ end # Engine
73
+ end # StateGate
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir[File.join(__dir__, 'engine', '*.rb')].sort.each { |file| require file }
4
+
5
+ module StateGate
6
+ ##
7
+ # = Description
8
+ #
9
+ # Contains a list of the _state-gate_ defined states, allowed transitions and configuration
10
+ # options.
11
+ #
12
+ # The engine is queried by all helper methods to validate states and allowed transitions.
13
+ #
14
+ class Engine
15
+
16
+ include Configurator
17
+ include Transitioner
18
+ include Sequencer
19
+ include Errator
20
+ include Scoper
21
+ include Stator
22
+ include Fixer
23
+
24
+
25
+
26
+ # ======================================================================
27
+ # = Private
28
+ # ======================================================================
29
+ private
30
+
31
+ # Initialize the engine, setting the Class and attribute for the new engine
32
+ # and parsing the provided configuration.
33
+ #
34
+ # StateGate::Engine.new(MyKlass, :status) do
35
+ # ... configuration ...
36
+ # end
37
+ #
38
+ def initialize(klass, attribute, &config)
39
+ aerror(:klass_type_err) unless klass.respond_to?(:to_s)
40
+ aerror(:attribute_type_err) unless attribute.is_a?(Symbol)
41
+ aerror(:missing_config_block_err) unless block_given?
42
+
43
+ @klass = klass
44
+ @attribute = StateGate.symbolize(attribute)
45
+
46
+ set_defaults
47
+
48
+ parse_configuration(&config)
49
+ end
50
+
51
+
52
+
53
+ # Set the class variables with default values
54
+ def set_defaults
55
+ @states = {}
56
+ @default = nil
57
+ @prefix = nil
58
+ @suffix = nil
59
+ @scopes = true
60
+ @sequential = false
61
+ @transitionless = false
62
+ end
63
+
64
+ end # Engine
65
+ end # StateGate
@@ -0,0 +1,14 @@
1
+ en:
2
+ state_gate:
3
+ builder:
4
+
5
+ non_class_err: "StateGate requires %{klass} to be a Class."
6
+ non_active_record_err: "StateGate requires %{klass} to derive from ActiveRecord."
7
+ missing_attribute_err: "Missing attribute name when using 'state_gate' in class '%{klass}'."
8
+ attribute_type_err: "StateGate <attr> must be a Symbol."
9
+ non_db_attr_err: "%{kattr} is not a database attribute."
10
+ non_string_column_err: "StateGate requires %{kattr} to be a database :string type."
11
+ existing_state_gate_err: "An StateGate has already been defined for %{kattr}."
12
+ conflict_err: "StateGate for %{klass}#%{attribute} will generate %{type} method '%{method_name}', which is already defined by %{source}."
13
+
14
+
@@ -0,0 +1,58 @@
1
+ en:
2
+ state_gate:
3
+ engine:
4
+
5
+ # ======================================================================
6
+ # = Engine
7
+ # ======================================================================
8
+
9
+ attribute_type_err: "The provided attribute must be a Symbol."
10
+ invalid_state_err: "%{val} is not valid state for %{kattr}."
11
+ klass_type_err: "The proved class must be a Class or String."
12
+ missing_config_block_err: "No configuration block provided."
13
+
14
+
15
+
16
+ # ======================================================================
17
+ # = Engine Configuration
18
+ # ======================================================================
19
+
20
+ config:
21
+ bad_command: "'%{cmd}' is not a valid configuration option."
22
+ default_repeat_err: "default for %{kattr} has been specified multiple times."
23
+ default_state_err: "The default set for %{kattr} is not a valid state."
24
+ default_type_err: "default for %{kattr} must be a Symbol."
25
+ prefix_multiple_err: "prefix for %{kattr} has been defined multiple times."
26
+ prefix_type_err: "prefix for %{kattr} must be a Symbol."
27
+ single_state_err: "%{kattr} needs at least two states defined."
28
+ state_force_name_err: "%{kattr}:%{state} states cannot begin with 'force_'."
29
+ state_not_name_err: "%{kattr}:%{state} states cannot begin with 'not_'."
30
+ state_opts_key_type_err: "options for %{kattr}:%{state} must be Symbols."
31
+ state_repeat_err: "%{kattr}:%{state} has been defined multiple times."
32
+ state_type_err: "states for %{kattr} must be a Symbol."
33
+ states_missing_err: "no states have been defined for %{kattr}."
34
+ suffix_multiple_err: "suffix for %{kattr} has been defined multiple times."
35
+ suffix_type_err: "suffix for %{kattr} must be a Symbol."
36
+ transition_key_type_err: "transitions for %{kattr}:%{state} must be Symbols."
37
+ any_transition_err: "when transitioning to :any on %{kattr}:%{state}, :any must be the only transition."
38
+ transition_state_err: "%{kattr} transitions from :%{state} to invalid state :%{transition}."
39
+ transitionless_states_err: "There are no state transitions leading to %{kattr} %{states}."
40
+
41
+
42
+
43
+ # ======================================================================
44
+ # = Engine Sequencer
45
+ # ======================================================================
46
+
47
+ non_sequential_err: "%{kattr} is not sequential and does not implement sequence methods."
48
+
49
+
50
+
51
+ # ======================================================================
52
+ # = Engine Transitioner
53
+ # ======================================================================
54
+
55
+ invalid_state_transition_err: "%{kattr} cannot transition from :%{from} to :%{to}."
56
+
57
+
58
+
@@ -0,0 +1,5 @@
1
+ en:
2
+ state_gate:
3
+ non_symbol_sm_name_err: "StateGate#state_method_name must be a Symbol"
4
+ included_err: "StateGate requires %{base} to derive from ActiveRecord."
5
+ extended_err: "%{base} should use 'include StateGate' and not 'extend StateGate'."
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # = Description
5
+ #
6
+ # RSpec matcher to verify allowed state transitions.
7
+ #
8
+ # [:source_obj]
9
+ # The Class or Instance to be tested.
10
+ #
11
+ # [:attr_name]
12
+ # The attrbute being tested.
13
+ #
14
+ # [:from]
15
+ # The state being transtioned from
16
+ #
17
+ # [:to]
18
+ # The states being transitions to
19
+ #
20
+ # expect(User).to allow_transitions_on(:status).from(:active).to(:suspended, :archived)
21
+ #
22
+ # Fails if a given transitions is not allowed, or an allowed transition is missing.
23
+ #
24
+ RSpec::Matchers.define :allow_transitions_on do |attr_name| # rubocop:disable Metrics/BlockLength
25
+ ##
26
+ # Expect the given attribute state to match all given transitions.
27
+ #
28
+ match do |source_obj| # :nodoc:
29
+ # validate we have a state engine and parameters
30
+ return false unless valid_setup?(attr_name, source_obj)
31
+
32
+ allowed_transitions = source_obj.stateables[@key]
33
+ .transitions_for_state(@state)
34
+ expected_transitions = @to.map(&:to_s).map(&:to_sym)
35
+ @missing_states = allowed_transitions - expected_transitions
36
+ @extra_states = expected_transitions - allowed_transitions
37
+
38
+ @error = :missing_states if @missing_states.any?
39
+ @error = :extra_states if @extra_states.any?
40
+
41
+ @error ? false : true
42
+ end
43
+
44
+
45
+
46
+ # Expect the attribute state not to have any given transitions.
47
+ #
48
+ match_when_negated do |source_obj|
49
+ # validate we have a state engine and parameters
50
+ return false unless valid_setup?(attr_name, source_obj)
51
+
52
+ allowed_transitions = source_obj.stateables[@key]
53
+ .transitions_for_state(@state)
54
+ expected_transitions = @to.map(&:to_s).map(&:to_sym)
55
+ remaining_states = expected_transitions - allowed_transitions
56
+
57
+ unless remaining_states.count == expected_transitions.count
58
+ @error = :found_states
59
+ @found_states = expected_transitions - remaining_states
60
+ end
61
+
62
+ @error ? false : true
63
+ end
64
+
65
+
66
+
67
+ # The state to be checked.
68
+ chain :from do |state|
69
+ @state = state
70
+ end
71
+
72
+
73
+
74
+ # The transitions to check
75
+ chain :to do |*transitions|
76
+ @to = transitions.flatten
77
+ @to_called = true
78
+ end
79
+
80
+
81
+
82
+ # Failure messages for an expected match.
83
+ #
84
+ failure_message do
85
+ case @error
86
+ when :no_state_gates
87
+ "no state machines are defined for #{@source_name}."
88
+
89
+ when :invalid_key
90
+ "no state machine is defined for ##{@key}."
91
+
92
+ when :invalid_state
93
+ ":#{@state} is not a valid state for #{@source_name}##{@key}."
94
+
95
+ when :no_from
96
+ 'missing ".from(<state>)".'
97
+
98
+ when :no_to
99
+ 'missing ".to(<states>)".'
100
+
101
+ when :invalid_transition_states
102
+ states = @invalid_states.map { |s| ":#{s}" }
103
+ if states.one?
104
+ "#{states.first} is not a valid ##{@key} state."
105
+ else
106
+ "#{states.to_sentence} are not valid ##{@key} states."
107
+ end
108
+
109
+ when :missing_states
110
+ states = @missing_states.map { |s| ":#{s}" }
111
+ "##{@key} also transitions from :#{@state} to #{states.to_sentence}."
112
+
113
+ when :extra_states
114
+ states = @extra_states.map { |s| ":#{s}" }
115
+ "##{@key} does not transition from :#{@state} to #{states.to_sentence}."
116
+ end
117
+ end
118
+
119
+
120
+
121
+ # failure messages for a negated match.
122
+ #
123
+ failure_message_when_negated do
124
+ case @error
125
+ when :no_state_gates
126
+ "no state machines are defined for #{@source_name}."
127
+
128
+ when :invalid_key
129
+ "no state machine is defined for ##{@key}."
130
+
131
+ when :invalid_state
132
+ ":#{@state} is not a valid state for #{@source_name}##{@key}."
133
+
134
+ when :no_from
135
+ 'missing ".from(<state>)".'
136
+
137
+ when :no_to
138
+ 'missing ".to(<states>)".'
139
+
140
+ when :invalid_transition_states
141
+ states = @invalid_states.map { |s| ":#{s}" }
142
+ if states.one?
143
+ "#{states.first} is not a valid ##{@key} state."
144
+ else
145
+ "#{states.to_sentence} are not valid ##{@key} states."
146
+ end
147
+
148
+ when :found_states
149
+ states = @found_states.map { |s| ":#{s}" }
150
+ ":#{@state} is allowed to transition to #{states.to_sentence}."
151
+ end
152
+ end
153
+
154
+
155
+
156
+ # = Helpers
157
+ # ======================================================================
158
+
159
+ # Check the setup is correct with the required information available.
160
+ #
161
+ def valid_setup?(attr_name, source_obj) # :nodoc:
162
+ @key = StateGate.symbolize(attr_name)
163
+ @state = StateGate.symbolize(@state)
164
+ @source_name = source_obj.is_a?(Class) ? source_obj.name : source_obj.class.name
165
+
166
+ # detect_setup_errors(source_obj)
167
+
168
+ return false unless assert_state_gate(source_obj)
169
+ return false unless assert_valid_key(source_obj)
170
+ return false unless assert_from_present
171
+ return false unless assert_valid_state
172
+ return false unless assert_to_present
173
+
174
+ assert_valid_transition
175
+ end
176
+
177
+
178
+
179
+ # Validate the state machines container exists
180
+ #
181
+ def assert_state_gate(source_obj)
182
+ return true if source_obj.respond_to?(:stateables)
183
+
184
+ @error = :no_state_gates
185
+ false
186
+ end
187
+
188
+
189
+
190
+ # Validate the state machine is there
191
+ #
192
+ def assert_valid_key(source_obj)
193
+ @eng = source_obj.stateables[@key]
194
+ return true unless @eng.blank?
195
+
196
+ @error = :invalid_key
197
+ false
198
+ end
199
+
200
+
201
+
202
+ # Validate the :from state is present
203
+ #
204
+ def assert_from_present
205
+ return true unless @state.blank?
206
+
207
+ @error = :no_from
208
+ false
209
+ end
210
+
211
+
212
+
213
+ # Validate it is a valid state supplied
214
+ #
215
+ def assert_valid_state
216
+ return true if @eng.states.include?(@state)
217
+
218
+ @error = :invalid_state
219
+ false
220
+ end
221
+
222
+
223
+
224
+ # Validate the transitions have been supplied
225
+ #
226
+ def assert_to_present
227
+ return true if @to_called
228
+
229
+ @error = :no_to
230
+ false
231
+ end
232
+
233
+
234
+
235
+ # Validate the supplied transitions are valid
236
+ #
237
+ def assert_valid_transition
238
+ return true unless invalid_transition_states?
239
+
240
+ @error = :invalid_transition_states
241
+ false
242
+ end
243
+
244
+
245
+
246
+ # Check the supplied transition states are valid for the attribute.
247
+ #
248
+ def invalid_transition_states? # :nodoc:
249
+ @invalid_states = []
250
+ @to.each do |state|
251
+ unless @eng.states.include?(state.to_s.to_sym)
252
+ @invalid_states << state.to_s.to_sym
253
+ @error = :invalid_transition_states
254
+ end
255
+ end
256
+
257
+ @invalid_states.any? ? true : false
258
+ end
259
+ end