steady_state 0.0.1
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/README.md +259 -0
- data/Rakefile +16 -0
- data/lib/steady_state.rb +16 -0
- data/lib/steady_state/attribute.rb +58 -0
- data/lib/steady_state/attribute/state.rb +25 -0
- data/lib/steady_state/attribute/state_machine.rb +31 -0
- data/lib/steady_state/attribute/transition_validator.rb +9 -0
- data/lib/steady_state/version.rb +3 -0
- data/spec/spec_helper.rb +1 -0
- data/spec/steady_state/attribute_spec.rb +370 -0
- metadata +129 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: b6ac44424729680f31b79e25aa8b110323aee6ec97824f19ac03f54921bc2213
|
4
|
+
data.tar.gz: 77f39b19b580b0930864cf8a867a9e41ae4fc4900356a65123e83359729110f6
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 35ac9ceff1d5997e97b3a40840629b5665d26175e491e8ae28df4dd3fa99a64b4aa419122e60a3fc8372e9a6ccc0afa8c07136ea067eca9662d47d65f4099a58
|
7
|
+
data.tar.gz: e5394a77afb844a74d72f020f22fb343ec1d2d3bea2cdc98a1a0d2db2c6e3a28d0b85cd930b47a4221946830d40f0c52acc9f217d9262337f7c3b4f692678fc0
|
data/README.md
ADDED
@@ -0,0 +1,259 @@
|
|
1
|
+
# SteadyState
|
2
|
+
|
3
|
+
> A minimalist approach to managing object state, perhaps best described as an "enum with guard rails." Designed to work with `ActiveRecord` and `ActiveModel` classes, or anywhere where Rails validations are used.
|
4
|
+
|
5
|
+
## Overview
|
6
|
+
|
7
|
+
SteadyState takes idea of a [Finite State Machine](https://en.wikipedia.org/wiki/Finite-state_machine) and cuts out everything but the most basic declaration of states and transitions (i.e. a directed graph). It then uses ActiveModel validations to enforce these transition rules, and plays nicely with other `validates`/`validate` declarations on your model.
|
8
|
+
|
9
|
+
All of the features one might expect of a Finite State Machine—take, for example, named events, conditional rules, transition hooks, and event callbacks—can then be implemented using existing methods like `valid?`, `errors`, `after_save`, and so on. This approach is effective in contexts that already rely on these methods for control flow (e.g. a Rails controller).
|
10
|
+
|
11
|
+
Both `ActiveRecord` and `ActiveModel` classes are supported, as well as any class adhering to the `ActiveModel::Validations` APIs.
|
12
|
+
|
13
|
+
## Installation
|
14
|
+
|
15
|
+
Add this to your Gemfile:
|
16
|
+
|
17
|
+
```
|
18
|
+
gem 'steady_state'
|
19
|
+
```
|
20
|
+
|
21
|
+
## Getting Started
|
22
|
+
|
23
|
+
To enable stateful behavior on an attribute or column, call `steady_state` with the name of the attribute, and define the states as strings, like so:
|
24
|
+
|
25
|
+
```ruby
|
26
|
+
class Material
|
27
|
+
include ActiveModel::Model
|
28
|
+
include SteadyState
|
29
|
+
|
30
|
+
attr_accessor :state
|
31
|
+
|
32
|
+
steady_state :state do
|
33
|
+
state 'solid', default: true
|
34
|
+
state 'liquid', from: 'solid'
|
35
|
+
state 'gas', from: 'liquid'
|
36
|
+
state 'plasma', from: 'gas'
|
37
|
+
end
|
38
|
+
end
|
39
|
+
```
|
40
|
+
|
41
|
+
The `:from` option specifies the state transition rules, i.e. which state(s) a given state is allowed to transition from. It may accept either a single state, or a list of states:
|
42
|
+
|
43
|
+
```ruby
|
44
|
+
state 'cancelled', from: %w(step-1 step-2)
|
45
|
+
```
|
46
|
+
|
47
|
+
The `:default` option defines the state that your object will start in if no other state is provided:
|
48
|
+
|
49
|
+
```ruby
|
50
|
+
material = Material.new
|
51
|
+
material.state # => 'solid'
|
52
|
+
```
|
53
|
+
|
54
|
+
You may always instantiate a new object in any state, regardless of the default:
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
material = Material.new(state: 'liquid')
|
58
|
+
material.state # => 'liquid'
|
59
|
+
```
|
60
|
+
|
61
|
+
A class may have any number of these `steady_state` declarations, one per stateful attribute.
|
62
|
+
|
63
|
+
### Moving Between States
|
64
|
+
|
65
|
+
After your object has been instantiated (or loaded from a database via your ORM), the transitional validations and rules begin to take effect. To change the state, simply use the attribute's setter (e.g. `state=`), and then call `valid?` to see if the change will be accepted:
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
material.state.solid? # => true
|
69
|
+
material.state = 'liquid'
|
70
|
+
material.state # => 'liquid'
|
71
|
+
material.valid? # => true
|
72
|
+
```
|
73
|
+
|
74
|
+
If the change is not valid, a validation error will be added to the object:
|
75
|
+
|
76
|
+
```ruby
|
77
|
+
material.state.liquid? # => true
|
78
|
+
material.state = 'solid'
|
79
|
+
material.state # => 'solid'
|
80
|
+
material.valid? # => false
|
81
|
+
material.errors[:state] # => ['is invalid']
|
82
|
+
```
|
83
|
+
|
84
|
+
#### A Deliberate Design Choice
|
85
|
+
|
86
|
+
Notice that even when the rules are violated, the state attribute does not revert to the previous state, nor is an exception raised inside of the setter. This is a deliberate design decision.
|
87
|
+
|
88
|
+
Compare this behavior to, say, a numericality validation:
|
89
|
+
|
90
|
+
```ruby
|
91
|
+
validates :amount, numericality: { greater_than: 0 }
|
92
|
+
|
93
|
+
model = MyModel.new(amount: -100)
|
94
|
+
model.amount # => -100
|
95
|
+
model.valid? # false
|
96
|
+
model.errors[:amount] # => ['must be greater than 0']
|
97
|
+
```
|
98
|
+
|
99
|
+
In keeping with the general pattern of `ActiveModel::Validations`, we rely on an object's _current state in memory_ to determine whether or not it is valid. For both the `state` and `amount` fields, the attribute is allowed to hold an invalid value, resulting in a validation error on the object.
|
100
|
+
|
101
|
+
#### Custom Validations
|
102
|
+
|
103
|
+
In addition to the built-in transitional validations, you can define your own validations that take effect only when the object enters a specific state:
|
104
|
+
|
105
|
+
```ruby
|
106
|
+
validates :container, absence: true, if: :solid?
|
107
|
+
validates :container, presence: true, if: :liquid?
|
108
|
+
```
|
109
|
+
|
110
|
+
With such a validation in place, a state change will not be valid unless the related validation rules are resolved at the same time:
|
111
|
+
|
112
|
+
```ruby
|
113
|
+
object.update!(state: 'liquid') # !! ActiveRecord::RecordInvalid
|
114
|
+
object.update!(state: 'liquid', container: Cup.new) # 🎉
|
115
|
+
```
|
116
|
+
|
117
|
+
With these tools, you can define rich sets of state-aware rules about your object, and then rely simply on built-in methods like `valid?` and `errors` to determine if an operation violates these rules.
|
118
|
+
|
119
|
+
### Bringing it All Together
|
120
|
+
|
121
|
+
Commonly, state transition events are expected to have names, like "melt" and "evaporate," and other such _action verbs_. SteadyState has no such expectation, and will not define any named events for you.
|
122
|
+
|
123
|
+
If you need them, we encourage you to define these transitions using plain ol' Ruby methods, like so:
|
124
|
+
|
125
|
+
```ruby
|
126
|
+
def melt
|
127
|
+
self.state = 'liquid'
|
128
|
+
valid? # will return `false` if state transition is invalid
|
129
|
+
end
|
130
|
+
|
131
|
+
def melt!
|
132
|
+
self.state = 'liquid'
|
133
|
+
validate! # will raise an exception if state transition is invalid
|
134
|
+
end
|
135
|
+
```
|
136
|
+
|
137
|
+
If you are using ActiveRecord, you can rely on built-in persistence methods to construct your state transition operations:
|
138
|
+
|
139
|
+
```ruby
|
140
|
+
def melt
|
141
|
+
update(state: 'liquid')
|
142
|
+
end
|
143
|
+
|
144
|
+
def melt!
|
145
|
+
update!(state: 'liquid')
|
146
|
+
end
|
147
|
+
```
|
148
|
+
|
149
|
+
For complex persistence operations, we encourage the use of existing persistence helpers like `transaction` and `with_lock`, to provide atomicity and avoid race conditions:
|
150
|
+
|
151
|
+
```ruby
|
152
|
+
def melt
|
153
|
+
with_lock do
|
154
|
+
if update(state: 'liquid', melted_at: Time.zone.now)
|
155
|
+
owner.update!(melt_count: owner.lock!.melt_count + 1)
|
156
|
+
Delayed::Job.enqueue MeltNotificationJob.new(self)
|
157
|
+
true
|
158
|
+
else
|
159
|
+
false
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
```
|
164
|
+
|
165
|
+
Here is an example Rails controller making use of this new `melt` method:
|
166
|
+
|
167
|
+
|
168
|
+
```ruby
|
169
|
+
class MaterialsController < ApplicationController
|
170
|
+
def melt
|
171
|
+
@material = Material.find(params[:id])
|
172
|
+
if @material.melt
|
173
|
+
redirect_to material_path(@material)
|
174
|
+
else
|
175
|
+
render :edit
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
```
|
180
|
+
|
181
|
+
With the ability to define your states, apply transitional validations, and persist state changes, you should have everything you need to start using SteadyState inside of your application.
|
182
|
+
|
183
|
+
## Addional Features & Configuration
|
184
|
+
|
185
|
+
### Predicates
|
186
|
+
|
187
|
+
Predicate methods (or "Huh methods") are automatically defined for each state:
|
188
|
+
|
189
|
+
```ruby
|
190
|
+
material = Material.new
|
191
|
+
material.solid? # => true
|
192
|
+
material.liquid? # => false
|
193
|
+
```
|
194
|
+
|
195
|
+
You can disable these if, for instance, they conflict with other methods:
|
196
|
+
|
197
|
+
```ruby
|
198
|
+
steady_state :status, predicates: false do
|
199
|
+
# ...
|
200
|
+
end
|
201
|
+
```
|
202
|
+
|
203
|
+
Either way, predicate methods are always available on the value itself:
|
204
|
+
|
205
|
+
```ruby
|
206
|
+
material.status.solid? # => true
|
207
|
+
material.status.liquid? # => false
|
208
|
+
```
|
209
|
+
|
210
|
+
### Scopes
|
211
|
+
|
212
|
+
On ActiveRecord objects, scopes are automatically defined for each state:
|
213
|
+
|
214
|
+
```ruby
|
215
|
+
Material.solid # => query for 'solid' records
|
216
|
+
Material.liquid # => query for 'liquid' records
|
217
|
+
```
|
218
|
+
|
219
|
+
These can be disabled as well:
|
220
|
+
|
221
|
+
```ruby
|
222
|
+
steady_state :step, scopes: false do
|
223
|
+
# ...
|
224
|
+
end
|
225
|
+
```
|
226
|
+
|
227
|
+
### Next and Previous States
|
228
|
+
|
229
|
+
The `may_become?` method can be used to see if setting the state to a particular value would be allowed (ignoring all other validations):
|
230
|
+
|
231
|
+
```ruby
|
232
|
+
material.state.may_become?('gas') #=> true
|
233
|
+
material.state.may_become?('solid') #=> false
|
234
|
+
```
|
235
|
+
|
236
|
+
To get a list of all of the valid state transitions, use the `next_values` method:
|
237
|
+
|
238
|
+
```ruby
|
239
|
+
material.state.next_values # => ['gas']
|
240
|
+
```
|
241
|
+
|
242
|
+
As it stands, state history is not preserved, but it is still possible to get a list of all possible previous states using the `previous_values` method:
|
243
|
+
|
244
|
+
```ruby
|
245
|
+
material.state.previous_values # => ['solid']
|
246
|
+
```
|
247
|
+
|
248
|
+
## How to Contribute
|
249
|
+
|
250
|
+
We would love for you to contribute! Anything that benefits the majority of `steady_state` users—from a documentation fix to an entirely new feature—is encouraged.
|
251
|
+
|
252
|
+
Before diving in, check our issue tracker and consider creating a new issue to get early feedback on your proposed change.
|
253
|
+
|
254
|
+
#### Suggested Workflow
|
255
|
+
|
256
|
+
* Fork the project and create a new branch for your contribution.
|
257
|
+
* Write your contribution (and any applicable test coverage).
|
258
|
+
* Make sure all tests pass (bundle exec rake).
|
259
|
+
* Submit a pull request.
|
data/Rakefile
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
begin
|
2
|
+
require 'bundler/setup'
|
3
|
+
rescue LoadError
|
4
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
5
|
+
end
|
6
|
+
|
7
|
+
Bundler::GemHelper.install_tasks
|
8
|
+
|
9
|
+
require 'rubocop/rake_task'
|
10
|
+
RuboCop::RakeTask.new
|
11
|
+
|
12
|
+
require 'rspec/core'
|
13
|
+
require 'rspec/core/rake_task'
|
14
|
+
RSpec::Core::RakeTask.new(:spec)
|
15
|
+
|
16
|
+
task default: %i(rubocop spec)
|
data/lib/steady_state.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
require 'active_support/core_ext'
|
3
|
+
require 'active_model'
|
4
|
+
require 'steady_state/attribute'
|
5
|
+
|
6
|
+
module SteadyState
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
def self.active_record?(klass)
|
10
|
+
defined?(ActiveRecord::Base) && klass < ActiveRecord::Base
|
11
|
+
end
|
12
|
+
|
13
|
+
included do
|
14
|
+
include Attribute
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'steady_state/attribute/state'
|
2
|
+
require 'steady_state/attribute/state_machine'
|
3
|
+
require 'steady_state/attribute/transition_validator'
|
4
|
+
|
5
|
+
module SteadyState
|
6
|
+
module Attribute
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
included do
|
10
|
+
cattr_reader :state_machines do
|
11
|
+
Hash.new { |h, k| h[k] = StateMachine.new }
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
class_methods do
|
16
|
+
def steady_state(attr_name, predicates: true, scopes: SteadyState.active_record?(self), &block) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/LineLength
|
17
|
+
overrides = Module.new do
|
18
|
+
define_method :"validate_#{attr_name}_transition_to" do |next_value|
|
19
|
+
if public_send(attr_name).may_become?(next_value)
|
20
|
+
remove_instance_variable("@last_valid_#{attr_name}") if instance_variable_defined?("@last_valid_#{attr_name}")
|
21
|
+
elsif !instance_variable_defined?("@last_valid_#{attr_name}")
|
22
|
+
instance_variable_set("@last_valid_#{attr_name}", public_send(attr_name))
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
define_method :"#{attr_name}=" do |value|
|
27
|
+
unless instance_variable_defined?("@#{attr_name}_state_initialized")
|
28
|
+
instance_variable_set("@#{attr_name}_state_initialized", true)
|
29
|
+
end
|
30
|
+
public_send(:"validate_#{attr_name}_transition_to", value) if public_send(attr_name).present?
|
31
|
+
super(value)
|
32
|
+
end
|
33
|
+
|
34
|
+
define_method :"#{attr_name}" do |*args, &blk|
|
35
|
+
unless instance_variable_defined?("@#{attr_name}_state_initialized")
|
36
|
+
public_send(:"#{attr_name}=", state_machines[attr_name].start) if super(*args, &blk).blank?
|
37
|
+
instance_variable_set("@#{attr_name}_state_initialized", true)
|
38
|
+
end
|
39
|
+
last_valid_value = instance_variable_get("@last_valid_#{attr_name}") if instance_variable_defined?("@last_valid_#{attr_name}")
|
40
|
+
state_machines[attr_name].new_state super(*args, &blk), last_valid_value
|
41
|
+
end
|
42
|
+
end
|
43
|
+
prepend overrides
|
44
|
+
|
45
|
+
state_machines[attr_name].instance_eval(&block)
|
46
|
+
delegate(*state_machines[attr_name].predicates, to: attr_name, allow_nil: true) if predicates
|
47
|
+
if scopes
|
48
|
+
state_machines[attr_name].states.each do |state|
|
49
|
+
scope state.to_sym, -> { where(attr_name.to_sym => state) }
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
validates :"#{attr_name}", 'steady_state/attribute/transition' => true,
|
54
|
+
inclusion: { in: state_machines[attr_name].states }
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module SteadyState
|
2
|
+
module Attribute
|
3
|
+
class State < SimpleDelegator
|
4
|
+
attr_accessor :state_machine, :last_valid_value
|
5
|
+
|
6
|
+
def initialize(state_machine, current_value, last_valid_value)
|
7
|
+
self.state_machine = state_machine
|
8
|
+
self.last_valid_value = last_valid_value
|
9
|
+
super(current_value&.inquiry)
|
10
|
+
end
|
11
|
+
|
12
|
+
def may_become?(new_value)
|
13
|
+
next_values.include?(new_value)
|
14
|
+
end
|
15
|
+
|
16
|
+
def next_values
|
17
|
+
@next_values ||= state_machine.transitions[last_valid_value || self]
|
18
|
+
end
|
19
|
+
|
20
|
+
def previous_values
|
21
|
+
@previous_values ||= state_machine.transitions.select { |_, v| v.include?(last_valid_value || self) }.keys
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module SteadyState
|
2
|
+
module Attribute
|
3
|
+
class StateMachine
|
4
|
+
attr_accessor :start
|
5
|
+
|
6
|
+
def state(state, default: false, from: [])
|
7
|
+
states << state
|
8
|
+
self.start = state if default
|
9
|
+
[from].flatten(1).each do |from_state|
|
10
|
+
transitions[from_state] << state
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def new_state(value, last_valid_value)
|
15
|
+
State.new(self, value, last_valid_value) unless value.nil?
|
16
|
+
end
|
17
|
+
|
18
|
+
def states
|
19
|
+
@states ||= []
|
20
|
+
end
|
21
|
+
|
22
|
+
def predicates
|
23
|
+
states.map { |state| :"#{state.parameterize.underscore}?" }
|
24
|
+
end
|
25
|
+
|
26
|
+
def transitions
|
27
|
+
@transitions ||= Hash.new { |h, k| h[k] = [] }.with_indifferent_access
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'steady_state'
|
@@ -0,0 +1,370 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
RSpec.describe SteadyState::Attribute do
|
4
|
+
let(:steady_state_class) do
|
5
|
+
Class.new do
|
6
|
+
include ActiveModel::Model
|
7
|
+
include SteadyState
|
8
|
+
|
9
|
+
def self.model_name
|
10
|
+
ActiveModel::Name.new(self, nil, 'steady_state_class')
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
subject { steady_state_class.new }
|
15
|
+
|
16
|
+
shared_examples 'a basic state machine' do
|
17
|
+
it 'starts on initial state' do
|
18
|
+
expect(subject.state).to eq 'solid'
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'allows initialization to the initial state' do
|
22
|
+
expect(steady_state_class.new(state: 'solid')).to be_valid
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'allows initialization to other states' do
|
26
|
+
expect(steady_state_class.new(state: 'plasma')).to be_valid
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'adds validation errors when initializing to an invalid state' do
|
30
|
+
object = steady_state_class.new(state: 'banana')
|
31
|
+
expect(object).not_to be_valid
|
32
|
+
expect(object.errors[:state]).to match_array(['is not included in the list'])
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'allows valid transitions' do
|
36
|
+
expect(subject.state.may_become?('liquid')).to eq true
|
37
|
+
expect(subject.state.next_values).to match_array(['liquid'])
|
38
|
+
expect(subject.state.previous_values).to match_array([])
|
39
|
+
expect { subject.state = 'liquid' }.to change { subject.state }.from('solid').to('liquid')
|
40
|
+
expect(subject).to be_valid
|
41
|
+
|
42
|
+
expect(subject.state.may_become?('gas')).to eq true
|
43
|
+
expect(subject.state.next_values).to match_array(['gas'])
|
44
|
+
expect(subject.state.previous_values).to match_array(['solid'])
|
45
|
+
expect { subject.state = 'gas' }.to change { subject.state }.from('liquid').to('gas')
|
46
|
+
expect(subject).to be_valid
|
47
|
+
|
48
|
+
expect(subject.state.may_become?('plasma')).to eq true
|
49
|
+
expect(subject.state.next_values).to match_array(['plasma'])
|
50
|
+
expect(subject.state.previous_values).to match_array(['liquid'])
|
51
|
+
expect { subject.state = 'plasma' }.to change { subject.state }.from('gas').to('plasma')
|
52
|
+
expect(subject).to be_valid
|
53
|
+
expect(subject.state.next_values).to be_empty
|
54
|
+
expect(subject.state.previous_values).to match_array(['gas'])
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'adds validation errors for invalid transitions' do
|
58
|
+
expect(subject.state.may_become?('gas')).to eq false
|
59
|
+
expect { subject.state = 'gas' }.to change { subject.state }.from('solid').to('gas')
|
60
|
+
expect(subject).not_to be_valid
|
61
|
+
expect(subject.errors[:state]).to match_array(['is invalid'])
|
62
|
+
expect(subject.state.next_values).to match_array(['liquid'])
|
63
|
+
expect(subject.state.previous_values).to match_array([])
|
64
|
+
|
65
|
+
expect(subject.state.may_become?('plasma')).to eq false
|
66
|
+
expect { subject.state = 'plasma' }.to change { subject.state }.from('gas').to('plasma')
|
67
|
+
expect(subject).not_to be_valid
|
68
|
+
expect(subject.errors[:state]).to match_array(['is invalid'])
|
69
|
+
expect(subject.state.next_values).to match_array(['liquid'])
|
70
|
+
expect(subject.state.previous_values).to match_array([])
|
71
|
+
|
72
|
+
expect(subject.state.may_become?('solid')).to eq false
|
73
|
+
expect { subject.state = 'solid' }.to change { subject.state }.from('plasma').to('solid')
|
74
|
+
expect(subject).not_to be_valid
|
75
|
+
expect(subject.errors[:state]).to match_array(['is invalid'])
|
76
|
+
expect(subject.state.next_values).to match_array(['liquid'])
|
77
|
+
expect(subject.state.previous_values).to match_array([])
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
context 'with a single field and nothing fancy' do
|
82
|
+
before do
|
83
|
+
steady_state_class.module_eval do
|
84
|
+
attr_accessor :state
|
85
|
+
|
86
|
+
steady_state :state do
|
87
|
+
state 'solid', default: true
|
88
|
+
state 'liquid', from: 'solid'
|
89
|
+
state 'gas', from: 'liquid'
|
90
|
+
state 'plasma', from: 'gas'
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
it_behaves_like 'a basic state machine'
|
96
|
+
|
97
|
+
context 'with inheritance' do
|
98
|
+
let(:subclass) do
|
99
|
+
Class.new(steady_state_class) do
|
100
|
+
def initialize
|
101
|
+
# I do my own thing.
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
subject { subclass.new }
|
106
|
+
|
107
|
+
it_behaves_like 'a basic state machine'
|
108
|
+
end
|
109
|
+
|
110
|
+
context 'with an existing state value' do
|
111
|
+
before do
|
112
|
+
steady_state_class.module_eval do
|
113
|
+
def state
|
114
|
+
@state ||= 'liquid'
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
it 'starts on existing state' do
|
120
|
+
expect(subject.state).to eq 'liquid'
|
121
|
+
end
|
122
|
+
|
123
|
+
it 'does not allow initialization to an invalid next state' do
|
124
|
+
object = steady_state_class.new(state: 'solid')
|
125
|
+
expect(object).not_to be_valid
|
126
|
+
expect(object.errors[:state]).to match_array(['is invalid'])
|
127
|
+
end
|
128
|
+
|
129
|
+
it 'allows initialization to a valid next state' do
|
130
|
+
expect(steady_state_class.new(state: 'gas')).to be_valid
|
131
|
+
end
|
132
|
+
|
133
|
+
it 'adds validation errors when initializing to an invalid state' do
|
134
|
+
object = steady_state_class.new(state: 'banana')
|
135
|
+
expect(object).not_to be_valid
|
136
|
+
expect(object.errors[:state]).to match_array(['is invalid', 'is not included in the list'])
|
137
|
+
end
|
138
|
+
|
139
|
+
it 'allows valid transitions' do
|
140
|
+
expect(subject).to be_valid
|
141
|
+
expect(subject.state.may_become?('gas')).to eq true
|
142
|
+
expect(subject.state.next_values).to match_array(['gas'])
|
143
|
+
expect(subject.state.previous_values).to match_array(['solid'])
|
144
|
+
expect { subject.state = 'gas' }.to change { subject.state }.from('liquid').to('gas')
|
145
|
+
expect(subject).to be_valid
|
146
|
+
|
147
|
+
expect(subject.state.may_become?('plasma')).to eq true
|
148
|
+
expect(subject.state.next_values).to match_array(['plasma'])
|
149
|
+
expect(subject.state.previous_values).to match_array(['liquid'])
|
150
|
+
expect { subject.state = 'plasma' }.to change { subject.state }.from('gas').to('plasma')
|
151
|
+
expect(subject).to be_valid
|
152
|
+
expect(subject.state.next_values).to be_empty
|
153
|
+
expect(subject.state.previous_values).to match_array(['gas'])
|
154
|
+
end
|
155
|
+
|
156
|
+
it 'adds validation errors for invalid transitions' do
|
157
|
+
expect(subject.state.may_become?('plasma')).to eq false
|
158
|
+
expect { subject.state = 'plasma' }.to change { subject.state }.from('liquid').to('plasma')
|
159
|
+
expect(subject).not_to be_valid
|
160
|
+
expect(subject.errors[:state]).to match_array(['is invalid'])
|
161
|
+
expect(subject.state.next_values).to match_array(['gas'])
|
162
|
+
expect(subject.state.previous_values).to match_array(['solid'])
|
163
|
+
|
164
|
+
expect(subject.state.may_become?('solid')).to eq false
|
165
|
+
expect { subject.state = 'solid' }.to change { subject.state }.from('plasma').to('solid')
|
166
|
+
expect(subject).not_to be_valid
|
167
|
+
expect(subject.errors[:state]).to match_array(['is invalid'])
|
168
|
+
expect(subject.state.next_values).to match_array(['gas'])
|
169
|
+
expect(subject.state.previous_values).to match_array(['solid'])
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
context 'with a field reachable by multiple states' do
|
175
|
+
before do
|
176
|
+
steady_state_class.module_eval do
|
177
|
+
attr_accessor :step
|
178
|
+
|
179
|
+
steady_state :step do
|
180
|
+
state 'step-1', default: true
|
181
|
+
state 'step-2', from: 'step-1'
|
182
|
+
state 'cancelled', from: %w(step-1 step-2)
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
it 'allows transition from first state' do
|
188
|
+
expect(subject.step.may_become?('step-1')).to eq false
|
189
|
+
expect(subject.step.may_become?('step-2')).to eq true
|
190
|
+
expect(subject.step.may_become?('cancelled')).to eq true
|
191
|
+
expect(subject.step.next_values).to match_array(['cancelled', 'step-2'])
|
192
|
+
expect(subject.step.previous_values).to match_array([])
|
193
|
+
expect { subject.step = 'cancelled' }.to change { subject.step }.from('step-1').to('cancelled')
|
194
|
+
expect(subject.step.next_values).to match_array([])
|
195
|
+
expect(subject.step.previous_values).to match_array(['step-1', 'step-2'])
|
196
|
+
expect(subject).to be_valid
|
197
|
+
end
|
198
|
+
|
199
|
+
it 'allows transition from second state' do
|
200
|
+
expect(subject.step.may_become?('step-1')).to eq false
|
201
|
+
expect(subject.step.may_become?('step-2')).to eq true
|
202
|
+
expect(subject.step.may_become?('cancelled')).to eq true
|
203
|
+
expect(subject.step.next_values).to match_array(['cancelled', 'step-2'])
|
204
|
+
expect(subject.step.previous_values).to match_array([])
|
205
|
+
expect { subject.step = 'step-2' }.to change { subject.step }.from('step-1').to('step-2')
|
206
|
+
expect(subject).to be_valid
|
207
|
+
|
208
|
+
expect(subject.step.may_become?('step-1')).to eq false
|
209
|
+
expect(subject.step.may_become?('step-2')).to eq false
|
210
|
+
expect(subject.step.may_become?('cancelled')).to eq true
|
211
|
+
expect(subject.step.next_values).to match_array(['cancelled'])
|
212
|
+
expect(subject.step.previous_values).to match_array(['step-1'])
|
213
|
+
expect { subject.step = 'cancelled' }.to change { subject.step }.from('step-2').to('cancelled')
|
214
|
+
expect(subject.step.next_values).to match_array([])
|
215
|
+
expect(subject.step.previous_values).to match_array(['step-1', 'step-2'])
|
216
|
+
expect(subject).to be_valid
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
context 'with the predicates option' do
|
221
|
+
before do
|
222
|
+
options = opts
|
223
|
+
steady_state_class.module_eval do
|
224
|
+
attr_accessor :door
|
225
|
+
|
226
|
+
steady_state :door, options do
|
227
|
+
state 'open', default: true
|
228
|
+
state 'closed', from: 'open'
|
229
|
+
state 'locked', from: 'closed'
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
context 'default' do
|
235
|
+
let(:opts) { {} }
|
236
|
+
|
237
|
+
it 'defines a predicate method for each state' do
|
238
|
+
expect(subject).to respond_to(:open?)
|
239
|
+
expect(subject).to respond_to(:closed?)
|
240
|
+
expect(subject).to respond_to(:locked?)
|
241
|
+
|
242
|
+
expect(subject.open?).to eq true
|
243
|
+
expect(subject.closed?).to eq false
|
244
|
+
expect(subject.locked?).to eq false
|
245
|
+
|
246
|
+
subject.door = 'closed'
|
247
|
+
expect(subject.open?).to eq false
|
248
|
+
expect(subject.closed?).to eq true
|
249
|
+
expect(subject.locked?).to eq false
|
250
|
+
|
251
|
+
subject.door = 'locked'
|
252
|
+
expect(subject.open?).to eq false
|
253
|
+
expect(subject.closed?).to eq false
|
254
|
+
expect(subject.locked?).to eq true
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
context 'enabled' do
|
259
|
+
let(:opts) { { predicates: true } }
|
260
|
+
|
261
|
+
it 'defines a predicate method for each state' do
|
262
|
+
expect(subject).to respond_to(:open?)
|
263
|
+
expect(subject).to respond_to(:closed?)
|
264
|
+
expect(subject).to respond_to(:locked?)
|
265
|
+
|
266
|
+
expect(subject.open?).to eq true
|
267
|
+
expect(subject.closed?).to eq false
|
268
|
+
expect(subject.locked?).to eq false
|
269
|
+
|
270
|
+
subject.door = 'closed'
|
271
|
+
expect(subject.open?).to eq false
|
272
|
+
expect(subject.closed?).to eq true
|
273
|
+
expect(subject.locked?).to eq false
|
274
|
+
|
275
|
+
subject.door = 'locked'
|
276
|
+
expect(subject.open?).to eq false
|
277
|
+
expect(subject.closed?).to eq false
|
278
|
+
expect(subject.locked?).to eq true
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
context 'disabled' do
|
283
|
+
let(:opts) { { predicates: false } }
|
284
|
+
|
285
|
+
it 'does not define predicate methods' do
|
286
|
+
expect(subject).not_to respond_to(:open?)
|
287
|
+
expect(subject).not_to respond_to(:closed?)
|
288
|
+
expect(subject).not_to respond_to(:locked?)
|
289
|
+
end
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
context 'with the scopes option' do
|
294
|
+
let(:query_object) { double(where: []) } # rubocop:disable RSpec/VerifiedDoubles
|
295
|
+
|
296
|
+
before do
|
297
|
+
options = opts
|
298
|
+
steady_state_class.module_eval do
|
299
|
+
attr_accessor :car
|
300
|
+
|
301
|
+
def self.defined_scopes
|
302
|
+
@defined_scopes ||= {}
|
303
|
+
end
|
304
|
+
|
305
|
+
def self.scope(name, callable)
|
306
|
+
defined_scopes[name] ||= callable
|
307
|
+
end
|
308
|
+
|
309
|
+
steady_state :car, options do
|
310
|
+
state 'driving', default: true
|
311
|
+
state 'stopped', from: 'driving'
|
312
|
+
state 'parked', from: 'stopped'
|
313
|
+
end
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
context 'default' do
|
318
|
+
let(:opts) { {} }
|
319
|
+
|
320
|
+
it 'does not define scope methods' do
|
321
|
+
expect(steady_state_class.defined_scopes.keys).to eq []
|
322
|
+
end
|
323
|
+
|
324
|
+
context 'on an ActiveRecord' do
|
325
|
+
let(:steady_state_class) do
|
326
|
+
stub_const('ActiveRecord::Base', Class.new)
|
327
|
+
|
328
|
+
Class.new(ActiveRecord::Base) do
|
329
|
+
include ActiveModel::Model
|
330
|
+
include SteadyState
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
it 'defines a scope for each state' do
|
335
|
+
expect(steady_state_class.defined_scopes.keys).to eq %i(driving stopped parked)
|
336
|
+
|
337
|
+
expect(query_object).to receive(:where).with(car: 'driving')
|
338
|
+
query_object.instance_exec(&steady_state_class.defined_scopes[:driving])
|
339
|
+
expect(query_object).to receive(:where).with(car: 'stopped')
|
340
|
+
query_object.instance_exec(&steady_state_class.defined_scopes[:stopped])
|
341
|
+
expect(query_object).to receive(:where).with(car: 'parked')
|
342
|
+
query_object.instance_exec(&steady_state_class.defined_scopes[:parked])
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
context 'enabled' do
|
348
|
+
let(:opts) { { scopes: true } }
|
349
|
+
|
350
|
+
it 'defines a scope for each state' do
|
351
|
+
expect(steady_state_class.defined_scopes.keys).to eq %i(driving stopped parked)
|
352
|
+
|
353
|
+
expect(query_object).to receive(:where).with(car: 'driving')
|
354
|
+
query_object.instance_exec(&steady_state_class.defined_scopes[:driving])
|
355
|
+
expect(query_object).to receive(:where).with(car: 'stopped')
|
356
|
+
query_object.instance_exec(&steady_state_class.defined_scopes[:stopped])
|
357
|
+
expect(query_object).to receive(:where).with(car: 'parked')
|
358
|
+
query_object.instance_exec(&steady_state_class.defined_scopes[:parked])
|
359
|
+
end
|
360
|
+
end
|
361
|
+
|
362
|
+
context 'disabled' do
|
363
|
+
let(:opts) { { scopes: false } }
|
364
|
+
|
365
|
+
it 'does not define scope methods' do
|
366
|
+
expect(steady_state_class.defined_scopes.keys).to eq []
|
367
|
+
end
|
368
|
+
end
|
369
|
+
end
|
370
|
+
end
|
metadata
ADDED
@@ -0,0 +1,129 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: steady_state
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Nathan Griffith
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-10-23 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activemodel
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '4.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '4.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: activesupport
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '4.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '4.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rubocop-betterment
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
description: |
|
84
|
+
A minimalist approach to managing object state,
|
85
|
+
perhaps best described as "an enum with guard rails."
|
86
|
+
Designed to work with `ActiveRecord` and `ActiveModel` classes,
|
87
|
+
or anywhere where Rails validations are used.
|
88
|
+
email:
|
89
|
+
- nathan@betterment.com
|
90
|
+
executables: []
|
91
|
+
extensions: []
|
92
|
+
extra_rdoc_files: []
|
93
|
+
files:
|
94
|
+
- README.md
|
95
|
+
- Rakefile
|
96
|
+
- lib/steady_state.rb
|
97
|
+
- lib/steady_state/attribute.rb
|
98
|
+
- lib/steady_state/attribute/state.rb
|
99
|
+
- lib/steady_state/attribute/state_machine.rb
|
100
|
+
- lib/steady_state/attribute/transition_validator.rb
|
101
|
+
- lib/steady_state/version.rb
|
102
|
+
- spec/spec_helper.rb
|
103
|
+
- spec/steady_state/attribute_spec.rb
|
104
|
+
homepage:
|
105
|
+
licenses: []
|
106
|
+
metadata: {}
|
107
|
+
post_install_message:
|
108
|
+
rdoc_options: []
|
109
|
+
require_paths:
|
110
|
+
- lib
|
111
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
112
|
+
requirements:
|
113
|
+
- - ">="
|
114
|
+
- !ruby/object:Gem::Version
|
115
|
+
version: '0'
|
116
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
117
|
+
requirements:
|
118
|
+
- - ">="
|
119
|
+
- !ruby/object:Gem::Version
|
120
|
+
version: '0'
|
121
|
+
requirements: []
|
122
|
+
rubyforge_project:
|
123
|
+
rubygems_version: 2.7.7
|
124
|
+
signing_key:
|
125
|
+
specification_version: 4
|
126
|
+
summary: Minimalist state management via "an enum with guard rails"
|
127
|
+
test_files:
|
128
|
+
- spec/spec_helper.rb
|
129
|
+
- spec/steady_state/attribute_spec.rb
|