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,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateGate
4
+ class Builder
5
+ ##
6
+ # = Description
7
+ #
8
+ # Multiple private methods allowing StateGate::Builder to generate
9
+ # transition methods.
10
+ #
11
+ # * query the class for all allowed transitions:
12
+ # Klass.status_transitions # => { pending: [:active],
13
+ # # active: [:suspended, :archived],
14
+ # # suspended: [:active, :archived],
15
+ # # archived: [] }
16
+ #
17
+ # * query the class for the allowed transitions for the given state:
18
+ # Klass.status_transitions_for(:pending) # => [:active]
19
+ # Klass.status_transitions_for(:active) # => [:suspended, :archived]
20
+ #
21
+ # * list the allowed transitions from the current state:
22
+ # .status_transitions # => [:suspended, :archived]
23
+ #
24
+ # * query if a given transition is allowed from the current state:
25
+ # .status_transitions_to?(:active) # => true
26
+ #
27
+ module TransitionMethods
28
+
29
+ # Private
30
+ # ======================================================================
31
+ private
32
+
33
+
34
+
35
+ # Add instance methods to the klass that query the allowed transitions
36
+ #
37
+ def generate_transition_methods
38
+ _add__klass__attr_transitions
39
+ _add__klass__attr_transitions_for
40
+
41
+ _add__instance__attr_transitions
42
+ _add__instance__attr_transitions_to
43
+
44
+ return unless @alias
45
+
46
+ _add__klass__attr_transitions(@alias)
47
+ _add__klass__attr_transitions_for(@alias)
48
+
49
+ _add__instance__attr_transitions(@alias)
50
+ _add__instance__attr_transitions_to(@alias)
51
+ end
52
+
53
+
54
+
55
+ # ======================================================================
56
+ # Class methods
57
+ # ======================================================================
58
+
59
+ # Adds a Class method to return a Hash of the allowed transitions for the attribte
60
+ # eg:
61
+ # Klass.status_transitions # => { pending: [:active],
62
+ # active: [:suspended, :archived],
63
+ # suspended: [:active, :archived],
64
+ # archived: [] }
65
+ #
66
+ def _add__klass__attr_transitions(method_name = @attribute)
67
+ method_name = "#{method_name}_transitions"
68
+
69
+ add__klass__helper_method(method_name, __FILE__, __LINE__ - 2, %(
70
+ def #{method_name}
71
+ stateables[:#{@attribute}].transitions
72
+ end
73
+ ))
74
+ end
75
+
76
+
77
+
78
+ # Adds a Class method to return an Array of the allowed attribute transitions for
79
+ # the provided state.
80
+ # eg:
81
+ # Klass.status_transitions_for(:pending) # => [:active]
82
+ # Klass.status_transitions_for(:active) # => [:suspended, :archived]
83
+ # Klass.status_transitions_for(:dummy) # => ArgumentError
84
+ #
85
+ def _add__klass__attr_transitions_for(method_name = @attribute)
86
+ method_name = "#{method_name}_transitions_for"
87
+
88
+ add__klass__helper_method(method_name, __FILE__, __LINE__ - 2, %(
89
+ def #{method_name}(state)
90
+ stateables[:#{@attribute}].transitions_for_state(state)
91
+ end
92
+ ))
93
+ end
94
+
95
+
96
+
97
+ # ======================================================================
98
+ # Instance methods
99
+ # ======================================================================
100
+
101
+
102
+ # Adds an instance method to return an Array of the allowed transitions from
103
+ # the current attribute state.
104
+ # eg:
105
+ # .status_transitions # => [:active]
106
+ # .status_transitions # => [:suspended, :archived]
107
+ # .status_transitions # => []
108
+ #
109
+ def _add__instance__attr_transitions(method_name = @attribute)
110
+ method_name = "#{method_name}_transitions"
111
+
112
+ add__instance__helper_method(method_name, __FILE__, __LINE__ - 2, %(
113
+ def #{method_name}
114
+ stateables[:#{@attribute}].transitions_for_state(self[:#{@attribute}])
115
+ end
116
+ ))
117
+ end
118
+
119
+
120
+
121
+
122
+ # Adds an instance method to return TRUE if the current attribute state can
123
+ # transition to the queries status.
124
+ # eg:
125
+ # .status_transitions_to?(:active) # => true
126
+ # .status_transitions_to?(:archived) # => false
127
+ #
128
+ def _add__instance__attr_transitions_to(method_name = @attribute)
129
+ method_name = "#{method_name}_transitions_to?"
130
+
131
+ add__instance__helper_method(method_name, __FILE__, __LINE__ - 2, %(
132
+ def #{method_name}(query_state)
133
+ test_state = StateGate.symbolize(query_state)
134
+ stateables[:#{@attribute}].transitions_for_state(self[:#{@attribute}])
135
+ .include?(test_state)
136
+ end
137
+ ))
138
+ end
139
+
140
+ end # TransitionMethods
141
+ end # Builder
142
+ end # StateGate
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateGate
4
+ class Builder
5
+ ##
6
+ # = Description
7
+ #
8
+ # Multiple private methods allowing StateGate::Builder to generate
9
+ # attribute setter methods for transition validation.
10
+ #
11
+ # * initializing the attribute with +Class.new+ :
12
+ # Klass.new(status: :active) # => ArgumentError
13
+ #
14
+ # * initializing the attribute with +Class.create+ :
15
+ # Klass.create(status: :active) # => ArgumentError
16
+ #
17
+ # * initializing the attribute with <tt>Class.create!</tt> :
18
+ # Klass.create!(status: :active) # => ArgumentError
19
+ #
20
+ # * setting the attribute with +attr=+ :
21
+ # .status = :active # => :active
22
+ # .status = :archived # => ArgumentError
23
+ #
24
+ # * setting the attribute with <tt>[:attr]=</tt> :
25
+ # [:status] = :active # => :active
26
+ # [:status] = :archived # => ArgumentError
27
+ #
28
+ # * setting the attribute with <tt>attributes=</tt> :
29
+ # .attrubutes = {status: :active} # => :active
30
+ # .attributes = {status: :archived } # => ArgumentError
31
+ #
32
+ # * setting the attribute with <tt>assign_attributes</tt> :
33
+ # .assign_attrubutes(status: :active) # => :active
34
+ # .assign_attributes(status: :archived) # => ArgumentError
35
+ #
36
+ # * updating the attribute with <tt>Class.update</tt> :
37
+ # Klass.update(instance.id, status: :active) # => :active
38
+ # Klass.update(instance.id, status: :archived) # => ArgumentError
39
+ #
40
+ # * updating the attribute with <tt>.update</tt> :
41
+ # .update(status: :active) # => :active
42
+ # .update(status: :archived) # => ArgumentError
43
+ #
44
+ # * updating the attribute with <tt>.update_column</tt> :
45
+ # .update_column(:status, :active) # => :active
46
+ # .update_column(:status, :archived) # => ArgumentError
47
+ #
48
+ # * updating the attribute with <tt>.update_columns</tt> :
49
+ # .update_columns(status: :active) # => :active
50
+ # .update_columns(status: :archived) # => ArgumentError
51
+ #
52
+ # * updating the attribute with <tt>.write_attribute</tt> :
53
+ # .write_attribute(:status, :active) # => :active
54
+ # .write_attribute(:status, :archived) # => ArgumentError
55
+ #
56
+ #
57
+ # === | Forcing a change
58
+ #
59
+ # To force a status change that would otherwise be prohibited, preceed the
60
+ # new state with +force_+ :
61
+ # .status = :archived # => ArgumentError
62
+ # .status = :force_archived # => :archived
63
+ #
64
+ module TransitionValidationMethods
65
+
66
+ # Private
67
+ # ======================================================================
68
+ private
69
+
70
+
71
+
72
+ # Add prepended instance methods to the klass that catch all methods for
73
+ # updating the attribute and validated the new value is an allowed transition
74
+ #
75
+ # Note:
76
+ # These methods are only added if the engine has an
77
+ # include_transition_validations? status on initialisation
78
+ #
79
+ # Note:
80
+ # The three methods "<atrr>=(val)", "write_attribute(<attr>, val)" and
81
+ # "update_columns(<attr>: val)" cover all the possibilities of setting the
82
+ # attribute through ActiveRecord.
83
+ #
84
+ def generate_transition_validation_methods
85
+ return if @engine.transitionless?
86
+
87
+ _prepend__attribute_equals
88
+ _prepend__write_attribute
89
+ _prepend__update_columns
90
+ _prepend__initialize
91
+ end
92
+
93
+
94
+
95
+ # ======================================================================
96
+ # Prepend Module
97
+ # ======================================================================
98
+
99
+ # Dynamically generated module to hold the validation setter methods
100
+ # and is pre-pended to the class.
101
+ #
102
+ # A new module is create if it doesn't already exist.
103
+ #
104
+ # Note:
105
+ # the module is named "StateGate::<klass>TranstionValidationMethods"
106
+ #
107
+ def _transition_validation_module # rubocop:disable Metrics/MethodLength
108
+ @_transition_validation_module ||= begin
109
+ mod_name = 'StateGate_ValidationMethods'
110
+
111
+ if @klass.const_defined?(mod_name)
112
+ "#{@klass}::#{mod_name}".constantize
113
+ else
114
+ @klass.const_set(mod_name, Module.new)
115
+ mod = "#{@klass}::#{mod_name}".constantize
116
+ @klass.prepend mod
117
+ mod
118
+ end
119
+ end
120
+ end
121
+
122
+
123
+
124
+ # ======================================================================
125
+ # Instance methods
126
+ # ======================================================================
127
+
128
+ # Adds a method to overwrite the attribute :<attr>=(val) setter, raising an error
129
+ # if the supplied value is not a valid transition
130
+ # eg:
131
+ # .status = :archived # => ArgumentError
132
+ # .status - :active # => :active
133
+ #
134
+ # ==== actions
135
+ #
136
+ # + assert it's a valid transition
137
+ # + call super
138
+ #
139
+ def _prepend__attribute_equals
140
+ attr_name = @attribute
141
+
142
+ _transition_validation_module.module_eval(%(
143
+ def #{attr_name}=(new_val)
144
+ stateables[StateGate.symbolize(:#{attr_name})] \
145
+ &.assert_valid_transition!(self[:#{attr_name}], new_val)
146
+ super(new_val)
147
+ end
148
+ ), __FILE__, __LINE__ - 6)
149
+ end
150
+
151
+
152
+
153
+ # Adds a method to overwrite the instance :write_attribute(attr, val) setter,
154
+ # raising an error if the supplied value is not a valid transition
155
+ # eg:
156
+ # .write_attribute(:status, :archived) # => ArgumentError
157
+ # .write_attribute(:status, :active) # => :active
158
+ #
159
+ # ==== actions
160
+ #
161
+ # + loop through each attribute
162
+ # + get the base attribute name from any alias used
163
+ # + assert it's a valid transition
164
+ # + call super
165
+ #
166
+ def _prepend__write_attribute
167
+ return if _transition_validation_module.method_defined?(:write_attribute)
168
+
169
+ _transition_validation_module.module_eval(%(
170
+ def write_attribute(attrribute_name, new_val = nil)
171
+ name = attrribute_name.to_s.downcase
172
+ name = self.class.attribute_aliases[name] || name
173
+
174
+ stateables[StateGate.symbolize(name)] \
175
+ &.assert_valid_transition!(self[name], new_val)
176
+ super(attrribute_name, new_val)
177
+ end
178
+ ), __FILE__, __LINE__ - 9)
179
+ end
180
+
181
+
182
+
183
+ # Adds a method to overwrite the instance :update_columns(attr: val) setter,
184
+ # raising an error if the supplied value is not a valid transition
185
+ # eg:
186
+ # .update_columns(status: :archived) # => ArgumentError
187
+ # .update_columns(status: :active) # => :active
188
+ #
189
+ # ==== actions
190
+ #
191
+ # + loop through each attribute
192
+ # + get the base attribute name from any alias used
193
+ # + assert it's a valid transition
194
+ # + call super
195
+ #
196
+ def _prepend__update_columns # rubocop:disable Metrics/MethodLength
197
+ return if _transition_validation_module.method_defined?(:update_columns)
198
+
199
+ _transition_validation_module.module_eval(%(
200
+ def update_columns(args)
201
+ super(args) and return if (new_record? || destroyed?)
202
+
203
+ args.each do |key, value|
204
+ name = key.to_s.downcase
205
+ name = self.class.attribute_aliases[name] || name
206
+
207
+ stateables[StateGate.symbolize(name)] \
208
+ &.assert_valid_transition!(self[name], value)
209
+ end
210
+
211
+ super
212
+ end
213
+ ), __FILE__, __LINE__ - 14)
214
+ end
215
+
216
+
217
+
218
+ # Prepends an :itialize method to ensure the attribute is not set on initializing
219
+ # a new instance unless :forced.
220
+ #
221
+ # Klass.new(status: :archived) # => ArgumentError
222
+ #
223
+ def _prepend__initialize # rubocop:disable Metrics/MethodLength
224
+ return if _transition_validation_module.method_defined?(:initialize)
225
+
226
+ _transition_validation_module.module_eval(%(
227
+ def initialize(attributes = nil, &block)
228
+ attributes&.each do |attr_name, value|
229
+ key = self.class.attribute_aliases[attr_name.to_s] || attr_name
230
+ if self.stateables.keys.include?(key.to_sym)
231
+ unless value.to_s.start_with?('force_')
232
+ msg = ":\#{attr_name} may not be included in the parameters for a new" \
233
+ " \#{self.class.name}. Create the new instance first, then transition" \
234
+ " :\#{attr_name} as required."
235
+ fail ArgumentError, msg
236
+ end
237
+ end
238
+ end
239
+
240
+ super
241
+ end
242
+ ), __FILE__, __LINE__ - 13)
243
+ end
244
+
245
+ end # LockingMethods
246
+ end # Builder
247
+ end # StateGate
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'engine'
4
+ require_relative 'type'
5
+ Dir[File.join(__dir__, 'builder', '*.rb')].sort.each { |file| require file }
6
+
7
+ module StateGate
8
+ ##
9
+ # = Description
10
+ #
11
+ # Responsible for generating the state gate engine, along
12
+ # with the Class and Instance helper methods for the submitted Klass. Everything is
13
+ # generated from +#initialize+ when a new instance is created.
14
+ #
15
+ # Both Class and Instance methods are generated for:
16
+ #
17
+ # * state interaction
18
+ # * state sequences
19
+ # * state scopes
20
+ # * transition interaction
21
+ # * transition validation
22
+ #
23
+ class Builder
24
+
25
+ include StateMethods
26
+ include ScopeMethods
27
+ include TransitionMethods
28
+ include ConflictDetectionMethods
29
+ include TransitionValidationMethods
30
+ include DynamicModuleCreationMethods
31
+
32
+
33
+
34
+ # Private
35
+ # ======================================================================
36
+ private
37
+
38
+ # Initialize the Builder, creating the state gate and generating all the
39
+ # Class and Instace helper methods on the sumbitted klass.
40
+ #
41
+ # [:klass]
42
+ # The class containing the attribute to be cast as a state gate
43
+ # (Class | required)
44
+ #
45
+ # [:attribute_name]
46
+ # The name of the klass attribute to use for the state gate
47
+ # (Symbol | required)
48
+ #
49
+ # [:config]
50
+ # The configuration block for the state gate.
51
+ # (Block | required)
52
+ #
53
+ # StateGate::Builder.new(Klass, :attribute_name) do
54
+ # ... configuration ...
55
+ # end
56
+ #
57
+ def initialize(klass = nil, attribute_name = nil, &config)
58
+ @klass = klass
59
+ @attribute = attribute_name
60
+ @alias = nil
61
+
62
+ # assert the input is valid
63
+ _assert_klass_is_valid_for_state_gate
64
+ _parse_atttribute_name_for_alias
65
+ _assert_no_existing_state_gate_for_attribute
66
+ _assert_attribute_name_is_a_database_string_column
67
+
68
+ # build the engine and cast the attribute
69
+ _build_state_gate_engine(&config)
70
+ _cast_attribute_as_state_gate
71
+
72
+ # generate the helper methods
73
+ generate_helper_methods
74
+ end
75
+
76
+
77
+
78
+ # Generate the helper methods
79
+ #
80
+ def generate_helper_methods
81
+ # add the helper methods
82
+ generate_scope_methods
83
+ generate_state_methods
84
+ generate_transition_methods
85
+ generate_transition_validation_methods
86
+
87
+ # warn if any state gate attribute methods are redefined
88
+ _generate_method_redefine_detection
89
+ end
90
+
91
+
92
+
93
+ # Validate the klass to ensure it is a 'Class' and derived from ActiveRecord,
94
+ # raising an error if not.
95
+ #
96
+ def _assert_klass_is_valid_for_state_gate
97
+ err(:non_class_err, klass: true) unless @klass.is_a?(Class)
98
+ err(:non_ar_err, klass: true) unless @klass.ancestors.include?(::ActiveRecord::Base)
99
+ end
100
+
101
+
102
+
103
+ # Parse the attribute name to ensure it is a valid input value and
104
+ # detect if it's an attribute_alias.
105
+ #
106
+ # = meta
107
+ #
108
+ # * ensure it exists
109
+ # * ensure it's a Symbol, to avoid string whitespace issues
110
+ # * check is it's a registere attribute
111
+ # * update @attribute & @alias
112
+ #
113
+ def _parse_atttribute_name_for_alias
114
+ if @attribute.nil?
115
+ err :missing_attribute_err, klass: true
116
+
117
+ elsif !@attribute.is_a?(Symbol)
118
+ err :attribute_type_err
119
+
120
+ elsif @klass.attribute_aliases[@attribute.to_s]
121
+ @alias = @attribute
122
+ @attribute = @klass.attribute_aliases[@attribute.to_s].to_sym
123
+ end
124
+ end
125
+
126
+
127
+
128
+ # Validate we don't already have a state gate defined for the attribute,
129
+ # raising an error if not.
130
+ #
131
+ def _assert_no_existing_state_gate_for_attribute
132
+ return unless @klass.methods(false).include?(:stateables)
133
+ return unless @klass.stateables.keys.include?(@attribute)
134
+
135
+ err :existing_state_gate_err, kattr: true
136
+ end # parse_attribute_name
137
+
138
+
139
+
140
+ # Validate the attribute is a database String attribute,
141
+ # raising an error if not.
142
+ #
143
+ # = meta
144
+ #
145
+ # * ensure it's mapped to a database column
146
+ # * ensure it's a :string databse type
147
+ #
148
+ def _assert_attribute_name_is_a_database_string_column
149
+ if @klass.column_names.exclude?(@attribute.to_s)
150
+ err :non_db_attr_err, kattr: true
151
+
152
+ elsif @klass.columns_hash[@attribute.to_s].type != :string
153
+ err :non_string_column_err, kattr: true,
154
+ attr_type: @klass.columns_hash[@attribute.to_s].type
155
+ end
156
+ end # parse_attribute_name
157
+
158
+
159
+
160
+ # Builds a StateGate::Engine for the given attribute and add it to
161
+ # the :stateables repository.
162
+ #
163
+ # config - the user generated configuration for the engine, including states,
164
+ # transitions and optional settings
165
+ #
166
+ def _build_state_gate_engine(&config)
167
+ _initialize_state_gate_repository
168
+ @engine = StateGate::Engine.new(@klass.name, @attribute, &config)
169
+ @klass.stateables[@attribute] = @engine # rubocop:disable Layout/ExtraSpacing
170
+ end
171
+
172
+
173
+
174
+ # Adds a :stateables class_attribute if it doesn't already exist and
175
+ # initializes it to an empty Hash.
176
+ #
177
+ # :stateables contains is a repository for the state gate engines
178
+ # created when generating state gates. The state gate attribute name is used
179
+ # as the key.
180
+ #
181
+ # Example
182
+ # Klass.stateables # => {
183
+ # status: <StateGate::Engine>,
184
+ # account: <StateGate::Engine>
185
+ # }
186
+ #
187
+ # Note
188
+ #
189
+ # The default empty Hash is set after the attribute is created to accommodate
190
+ # ActiveRecord 5.0, even though ActiveRecord 6.0 allows it to be set within
191
+ # the .class_attribute method.
192
+ #
193
+ def _initialize_state_gate_repository
194
+ return if @klass.methods(false).include?(:stateables)
195
+
196
+ @klass.class_attribute(:stateables, instance_writer: false)
197
+ @klass.stateables = {}
198
+ end
199
+
200
+
201
+
202
+ # Builds a StateGate::Type with the custom states for the attribute,
203
+ # then casts the attribute.
204
+ #
205
+ # This ensures that regardless of the setter method used to set a new state, even
206
+ # if transition validations are disabled, an invalid state should never reach the
207
+ # database.
208
+ #
209
+ # meta
210
+ #
211
+ # * retrieve the root attribute name if the supplied attribute is an alias.
212
+ # * create a StateGate::Type with attributes states.
213
+ # * overwrite the attribute, casting the type as the new StateGate::Type
214
+ #
215
+ def _cast_attribute_as_state_gate
216
+ states = @engine.states
217
+ attr_type = StateGate::Type.new(@klass.name, @attribute, states)
218
+ @klass.attribute(@attribute, attr_type, default: @engine.default_state)
219
+ end
220
+
221
+
222
+
223
+ # Raise an ArgumentError for the given error, using I18n for the message.
224
+ #
225
+ # err - Symbol key for the I18n message
226
+ #
227
+ # args - Hash of attributes to pass to the message string.
228
+ #
229
+ # [:klass] When true, args[:klass] will be updated with the 'KlassName'.
230
+ # [:kattr] When true, args[:kattr] will be updated with 'KlassName#attribute'.
231
+ #
232
+ # Example
233
+ #
234
+ # err(:invalid_attribute_type_err, kattr: true)
235
+ #
236
+ def err(err, **args)
237
+ args[:klass] = @klass if args.dig(:klass) == true
238
+ args[:kattr] = "#{@klass}##{@attribute}" if args.dig(:kattr) == true
239
+
240
+ fail ArgumentError, I18n.t("state_gate.builder.#{err}", **args)
241
+ end
242
+
243
+ end # class Builder
244
+ end # module StateGate