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,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: []