state_gate 1.2.3
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|