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.
- checksums.yaml +7 -0
- data/lib/state_gate/builder/conflict_detection_methods.rb +134 -0
- data/lib/state_gate/builder/dynamic_module_creation_methods.rb +120 -0
- data/lib/state_gate/builder/scope_methods.rb +96 -0
- data/lib/state_gate/builder/state_methods.rb +289 -0
- data/lib/state_gate/builder/transition_methods.rb +142 -0
- data/lib/state_gate/builder/transition_validation_methods.rb +247 -0
- data/lib/state_gate/builder.rb +244 -0
- data/lib/state_gate/engine/configurator.rb +230 -0
- data/lib/state_gate/engine/errator.rb +63 -0
- data/lib/state_gate/engine/fixer.rb +75 -0
- data/lib/state_gate/engine/scoper.rb +66 -0
- data/lib/state_gate/engine/sequencer.rb +116 -0
- data/lib/state_gate/engine/stator.rb +225 -0
- data/lib/state_gate/engine/transitioner.rb +73 -0
- data/lib/state_gate/engine.rb +65 -0
- data/lib/state_gate/locale/builder_en.yml +14 -0
- data/lib/state_gate/locale/engine_en.yml +58 -0
- data/lib/state_gate/locale/state_gate_en.yml +5 -0
- data/lib/state_gate/rspec/allow_transitions_on.rb +259 -0
- data/lib/state_gate/rspec/have_states.rb +140 -0
- data/lib/state_gate/rspec.rb +6 -0
- data/lib/state_gate/type.rb +112 -0
- data/lib/state_gate.rb +193 -0
- metadata +83 -0
@@ -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,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: []
|