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,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # = Description
5
+ #
6
+ # RSpec matcher to verify defined states.
7
+ #
8
+ # [:source_obj]
9
+ # The Class or Instance to be tested.
10
+ #
11
+ # [:for]
12
+ # The attrbute being tested.
13
+ #
14
+ # [:states]
15
+ # The expected states as Symbols or Strings
16
+ #
17
+ # expect(User).to have_states(:pending, :active).for(:status)
18
+ #
19
+ # Fails if an exisiting state is missing or there are defined states that
20
+ # have not been included.
21
+ #
22
+ RSpec::Matchers.define :have_states do |*states| # rubocop:disable Metrics/BlockLength
23
+ #
24
+ # Expect the given states to match all the states for the attribute.
25
+ #
26
+ match do |source_obj| # :nodoc:
27
+ # validate we have a state engine and parameters
28
+ return false unless valid_setup?(states, source_obj)
29
+
30
+ @missing_states = @eng.states - @states
31
+ @extra_states = @states - @eng.states
32
+
33
+ @error = :missing_states if @missing_states.any?
34
+ @error = :extra_states if @extra_states.any?
35
+
36
+ @error ? false : true
37
+ end
38
+
39
+
40
+ # Expect the attribute not to have any given states.
41
+ #
42
+ match_when_negated do |source_obj|
43
+ # validate we have a state engine and parameters
44
+ return false unless valid_setup?(states, source_obj)
45
+
46
+ @valid_states = @states.select { |s| @eng.states.include?(s) }
47
+ @error = :valid_states_found if @valid_states.any?
48
+
49
+ @error ? false : true
50
+ end
51
+
52
+
53
+ # The attribute that should have the expected states.
54
+ #
55
+ chain :for do |attr_name|
56
+ @key = StateGate.symbolize(attr_name)
57
+ end
58
+
59
+
60
+
61
+ # Failure messages for an expected match.
62
+ #
63
+ failure_message do
64
+ case @error
65
+ when :no_state_gates
66
+ "no state machines are defined for #{@source_name}."
67
+
68
+ when :missing_key
69
+ 'missing ".for(<attribute>)".'
70
+
71
+ when :invalid_key
72
+ "no state machine is defined for ##{@key}."
73
+
74
+ when :missing_states
75
+ states = @missing_states.map { |s| ":#{s}" }
76
+ if states.one?
77
+ "#{states.first} is also a valid state for ##{@key}."
78
+ else
79
+ "#{states.to_sentence} are also valid states for ##{@key}."
80
+ end
81
+
82
+ when :extra_states
83
+ states = @extra_states.map { |s| ":#{s}" }
84
+ if states.one?
85
+ "#{states.first} is not a valid state for ##{@key}."
86
+ else
87
+ "#{states.to_sentence} are not valid states for ##{@key}."
88
+ end
89
+ end
90
+ end
91
+
92
+
93
+
94
+ # failure messages for a negated match.
95
+ #
96
+ failure_message_when_negated do
97
+ case @error
98
+ when :no_state_gates
99
+ "no state machines are defined for #{@source_name}."
100
+
101
+ when :missing_key
102
+ 'missing ".for(<attribute>)".'
103
+
104
+ when :invalid_key
105
+ "no state machine is defined for ##{@key}."
106
+
107
+ when :valid_states_found
108
+ states = @valid_states.map { |s| ":#{s}" }
109
+ if states.one?
110
+ "#{states.first} is a valid state for ##{@key}."
111
+ else
112
+ "#{states.to_sentence} are valid states for ##{@key}."
113
+ end
114
+ end
115
+ end
116
+
117
+
118
+
119
+ # Helpers
120
+ # ======================================================================
121
+
122
+ # Check the setup is correct with the required information available.
123
+ #
124
+ def valid_setup?(states, source_obj) # :nodoc:
125
+ @states = states.flatten.map { |s| StateGate.symbolize(s) }
126
+ @source_name = source_obj.is_a?(Class) ? source_obj.name : source_obj.class.name
127
+
128
+ if @key.blank?
129
+ @error = :missing_key
130
+
131
+ elsif !source_obj.respond_to?(:stateables)
132
+ @error = :no_state_gates
133
+
134
+ elsif (@eng = source_obj.stateables[@key]).blank?
135
+ @error = :invalid_key
136
+ end
137
+
138
+ @error ? false : true
139
+ end
140
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # relative-require all rspec files
4
+ Dir[File.dirname(__FILE__) + '/rspec/*.rb'].each do |file|
5
+ require_relative 'rspec/' + File.basename(file, File.extname(file))
6
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateGate
4
+ ##
5
+ # = Description
6
+ #
7
+ # ActiveRecord::Type to cast a model attribute as a StateGate,
8
+ # mapping to a string database column.
9
+ #
10
+ # Ensures that any string written to, or read from, the database is a valid +state+,
11
+ # otherwise it raises an exception.
12
+ #
13
+ # This class is has an internal API for ActiveRecord and is not intended for public use.
14
+ #
15
+ class Type < ::ActiveModel::Type::String
16
+
17
+ ##
18
+ # ensure the value is a legitimate state and return a downcased string
19
+ #
20
+ def cast(value) # :nodoc:
21
+ assert_valid_value(value)
22
+ value.to_s.downcase.remove(/^force_/)
23
+ end
24
+
25
+
26
+
27
+ ##
28
+ # Return TRUE if the value is serializable, otherwise FASLE.
29
+ #
30
+ # a value is serializable if it can be coerced to a String
31
+ #
32
+ def serializable?(value) # :nodoc:
33
+ value.to_s
34
+ rescue NoMethodError
35
+ false
36
+ end
37
+
38
+
39
+
40
+ ##
41
+ # Return a downcased String of the given value, providing it is a legitimate state.
42
+ #
43
+ def serialize(value) # :nodoc:
44
+ assert_valid_value(value)
45
+ value.to_s.downcase.remove(/^force_/)
46
+ end
47
+
48
+
49
+
50
+ ##
51
+ # Raise an exception unless the value is both serializable and a legitimate state
52
+ #
53
+ def assert_valid_value(value) # :nodoc:
54
+ return if serializable?(value) && states.include?(value.to_s.downcase.remove(/^force_/))
55
+
56
+ case value
57
+ when NilClass
58
+ fail ArgumentError, "'nil' is not a valid state for #{@klass}##{@name}."
59
+ when Symbol
60
+ fail ArgumentError, ":#{value} is not a valid state for #{@klass}##{@name}."
61
+ else
62
+ fail ArgumentError, "'#{value&.to_s}' is not a valid state for #{@klass}##{@name}."
63
+ end
64
+ end
65
+
66
+
67
+
68
+ ##
69
+ # Returns TRUE if the other class is equal, otherewise FALSE.
70
+ #
71
+ # Equality matches on Class, name and states(in the given order)
72
+ #
73
+ def ==(other) # :nodoc:
74
+ return false unless self.class == other.class
75
+ return false unless klass == other.send(:klass)
76
+ return false unless name == other.send(:name)
77
+ return false unless states == other.send(:states)
78
+
79
+ true
80
+ end
81
+ alias eql? ==
82
+
83
+
84
+
85
+ ##
86
+ # Returns a unique hash value
87
+ #
88
+ def hash # :nodoc:
89
+ [self.class, klass, name, states].hash
90
+ end
91
+
92
+
93
+
94
+ # ======================================================================
95
+ # = Private
96
+ # ======================================================================
97
+ private
98
+
99
+ attr_reader :klass, :name, :states
100
+
101
+
102
+
103
+ # initialize and set the class variables
104
+ #
105
+ def initialize(klass, name, states) # :nodoc:
106
+ @klass = klass
107
+ @name = name
108
+ @states = states.map(&:to_s)
109
+ end
110
+
111
+ end # class Type
112
+ end # StateGate
data/lib/state_gate.rb ADDED
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'state_gate/builder'
4
+
5
+
6
+ I18n.load_path << File.expand_path('state_gate/locale/engine_en.yml', __dir__)
7
+ I18n.load_path << File.expand_path('state_gate/locale/builder_en.yml', __dir__)
8
+ I18n.load_path << File.expand_path('state_gate/locale/state_gate_en.yml', __dir__)
9
+
10
+
11
+ ##
12
+ # = StateGate
13
+ #
14
+ # Builds and attaches a _StateGate::Engine_ to the desired ActiveRecord String
15
+ # attribute.
16
+ #
17
+ # *States* and *transitions* are provided within a configuration block, enabling
18
+ # the _state_gate_ to ensure that only defined *states* are accepted as values
19
+ # for the attribute, and that *states* may only transition to allowed *states*.
20
+ #
21
+ # Class User
22
+ # include StateGate
23
+ #
24
+ # state_gate :attribute_name do
25
+ # ... configuration ...
26
+ # end
27
+ # end
28
+ #
29
+ #
30
+ # == Attribute Name
31
+ #
32
+ # The +attribute_name+ *must* be:
33
+ # * a Symbol
34
+ # * the name of a database String column attribute
35
+ # * not an aliased attribute name
36
+ #
37
+ #
38
+ # == Configuration Options
39
+ #
40
+ # The configuration defines the state names, allowed transitions and a number of
41
+ # options to help customise the _state-gate_ to your exact preference.
42
+ #
43
+ # Options include:
44
+ #
45
+ # === | state
46
+ # Required name for the new state, supplied as a Symbol. The +state-gate+ requires
47
+ # a minimum of two states to be defined.
48
+ # state :state_name
49
+ #
50
+ #
51
+ # [:transitions_to]
52
+ # An optional list of the other state that this state is allowed to change to.
53
+ # state :state_1, transtions_to: [:state_2, :state_3, :state_4]
54
+ # state :state_2, transtions_to: :state_4
55
+ # state :state_3, transtions_to: :any
56
+ # state :state_4
57
+ #
58
+ # [:human]
59
+ # An optional String name to used when displaying gthe state in a view. If no
60
+ # name is specified, it will default to +:state.titleized+.
61
+ # state :state_1, transtions_to: [:state_2, :state_3], human: "My State"
62
+ #
63
+ #
64
+ # === | default
65
+ # Optional setting to specify the default state for a new object. The state name
66
+ # is given as a Symbol.
67
+ # default :state_name
68
+ #
69
+ #
70
+ # === | prefix
71
+ # Optional setting to add a given Symbol before each state name when using Class Scopes.
72
+ # This helps to differential between multiple attributes that have similar state names.
73
+ # prefix :before # => Class.before_active
74
+ #
75
+ #
76
+ # === | suffix
77
+ # Optional setting to add a given Symbol after each state name when using Class Scopes.
78
+ # This helps to differential between multiple attributes that have similar state names.
79
+ # suffix :after # => Class.active_after
80
+ #
81
+ #
82
+ # === | make_sequential
83
+ # Optional setting to automatically add transitions from each state to both the
84
+ # preceeding and following states.
85
+ # make_sequential
86
+ #
87
+ # [:one_way]
88
+ # Option to restrict the generated transitions to one directtion only: from each
89
+ # state to the follow state.
90
+ # make_sequential :one_way
91
+ #
92
+ # [:loop]
93
+ # Option to add transitions from the last state to the first and, unless +:one_way+
94
+ # is specified, also from the first state to the last.
95
+ # make_sequential :one_way, :loop
96
+ #
97
+ #
98
+ # === | no_scopes
99
+ # Optional setting to disable the generation of Class Scope helpers methods.
100
+ # no_scopes
101
+ #
102
+ module StateGate
103
+
104
+ ##
105
+ # Configuration Error for reporting issues with the configuration when
106
+ # building a new state machine
107
+ class ConfigurationError < StandardError # :nodoc:
108
+ end
109
+
110
+ ##
111
+ # Conflict Error for reporting when a generated method name
112
+ # conflicts with an existing method name
113
+ class ConflictError < StandardError # :nodoc:
114
+ end
115
+
116
+
117
+ # ======================================================================
118
+ # Model Public Singleton Methods
119
+ # ======================================================================
120
+
121
+ ##
122
+ # Returns the Symbol version of the provided value as long as it responds to
123
+ # +#to_s+ and has no included whitespace in the resulting String.
124
+ #
125
+ # Returns +nil+ if the coversion fails.
126
+ #
127
+ # StateGate.symbolize('Test') #=> :test
128
+ #
129
+ # StateGate.symbolize(:Test) #=> :test
130
+ #
131
+ # StateGate.symbolize('My Test') #=> nil
132
+ #
133
+ # StateGate.symbolize('') #=> nil
134
+ #
135
+ def self.symbolize(val)
136
+ return nil if val.blank?
137
+ return nil unless val.respond_to?(:to_s)
138
+ return nil unless val.to_s.remove(/\s+/) == val.to_s
139
+
140
+ val.to_s.downcase.to_sym
141
+ end
142
+
143
+
144
+
145
+ # ======================================================================
146
+ # Module Private Singleton methods
147
+ # ======================================================================
148
+
149
+ class << self
150
+
151
+
152
+ # Private
153
+ # ======================================================================
154
+ private
155
+
156
+
157
+
158
+ # When StateGate is included within a Class, check ActiveRecord is
159
+ # an ancestor and add the 'state_gate' method to the includeing Class
160
+ #
161
+ def included(base) #:nodoc:
162
+ ar_included = base.ancestors.include?(::ActiveRecord::Base)
163
+ fail I18n.t('state_gate.included_err', base: base.name) unless ar_included
164
+
165
+ generate_state_gate_method_for(base)
166
+ end
167
+ private_class_method :included
168
+
169
+
170
+
171
+ # Raise an exception when StateGate is 'extend' by another Class, to let
172
+ # the user know that it should be 'included'.
173
+ #
174
+ def extended(base) #:nodoc:
175
+ fail I18n.t('state_gate.extended_err', base: base.name)
176
+ end
177
+
178
+
179
+
180
+ # Calls an instance of StateGate::Builder to generate the
181
+ # 'state_gate' for the Klass attribute.
182
+ #
183
+ def generate_state_gate_method_for(klass)
184
+ klass.define_singleton_method(:state_gate) do |attr_name = nil, &block|
185
+ # Note: the builder does all it's work on initialize, so nothing more
186
+ # to do here.
187
+ StateGate::Builder.new(self, attr_name, &block)
188
+ end
189
+ end
190
+
191
+ end # class << self
192
+
193
+ end # module StateGate
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: state_gate
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.2.3
5
+ platform: ruby
6
+ authors:
7
+ - CodeMeister
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-11-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 5.0.0.beta1
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 5.0.0.beta1
27
+ description: " State management for ActiveRecord, with states; transitions; and
28
+ \ just the right amount of syntactic sugar. "
29
+ email: state_gate@codemeister.dev
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - lib/state_gate.rb
35
+ - lib/state_gate/builder.rb
36
+ - lib/state_gate/builder/conflict_detection_methods.rb
37
+ - lib/state_gate/builder/dynamic_module_creation_methods.rb
38
+ - lib/state_gate/builder/scope_methods.rb
39
+ - lib/state_gate/builder/state_methods.rb
40
+ - lib/state_gate/builder/transition_methods.rb
41
+ - lib/state_gate/builder/transition_validation_methods.rb
42
+ - lib/state_gate/engine.rb
43
+ - lib/state_gate/engine/configurator.rb
44
+ - lib/state_gate/engine/errator.rb
45
+ - lib/state_gate/engine/fixer.rb
46
+ - lib/state_gate/engine/scoper.rb
47
+ - lib/state_gate/engine/sequencer.rb
48
+ - lib/state_gate/engine/stator.rb
49
+ - lib/state_gate/engine/transitioner.rb
50
+ - lib/state_gate/locale/builder_en.yml
51
+ - lib/state_gate/locale/engine_en.yml
52
+ - lib/state_gate/locale/state_gate_en.yml
53
+ - lib/state_gate/rspec.rb
54
+ - lib/state_gate/rspec/allow_transitions_on.rb
55
+ - lib/state_gate/rspec/have_states.rb
56
+ - lib/state_gate/type.rb
57
+ homepage: https://github.com/Rubology/state_gate
58
+ licenses:
59
+ - MIT
60
+ metadata:
61
+ homepage_uri: https://github.com/Rubology/state_gate
62
+ source_code_uri: https://github.com/Rubology/state_gate
63
+ changelog_uri: https://github.com/Rubology/state_gate/blob/master/CHANGELOG.md
64
+ post_install_message:
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '2.5'
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.2.32
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: State management for ActiveRecord.
83
+ test_files: []