steady_state 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|