state_gate 1.2.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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