steady_state 0.0.1 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +68 -55
- data/lib/steady_state/attribute.rb +10 -1
- data/lib/steady_state/version.rb +1 -1
- data/spec/steady_state/attribute_spec.rb +33 -0
- metadata +3 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c80b6cce9dbf7702cdf8fd0d97c21794bcbc6a67c2ea688fcf5b150ee217df5a
|
4
|
+
data.tar.gz: 67bc8bc9e117e55ac21b4dcba713dbf97627354c1997e906fbb317ef06b42ac6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 374352c07075b3ccb50c1c018e6b8e654ebcc84675d44e4024406dde923ba28699f58daf7b67f8161cff8ee41d9e4f75c77f59680f165b62e527787b7dd00e65
|
7
|
+
data.tar.gz: 6d493623807a7332a1e3b96a9849d46b6a33d59e8405dbb935ef66de4d9b054b2c471ca8421feb7f75810a2aa6694b0173a64be694a69a31a6210de2232018b0
|
data/README.md
CHANGED
@@ -23,12 +23,9 @@ gem 'steady_state'
|
|
23
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
24
|
|
25
25
|
```ruby
|
26
|
-
class Material
|
27
|
-
include ActiveModel::Model
|
26
|
+
class Material < ApplicationRecord
|
28
27
|
include SteadyState
|
29
28
|
|
30
|
-
attr_accessor :state
|
31
|
-
|
32
29
|
steady_state :state do
|
33
30
|
state 'solid', default: true
|
34
31
|
state 'liquid', from: 'solid'
|
@@ -65,20 +62,19 @@ A class may have any number of these `steady_state` declarations, one per statef
|
|
65
62
|
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
63
|
|
67
64
|
```ruby
|
68
|
-
material.
|
69
|
-
material.state
|
70
|
-
material.state
|
71
|
-
material.
|
72
|
-
|
73
|
-
|
74
|
-
If the change is not valid, a validation error will be added to the object:
|
75
|
-
|
76
|
-
|
77
|
-
material.state
|
78
|
-
material.
|
79
|
-
material.state # => '
|
80
|
-
|
81
|
-
material.errors[:state] # => ['is invalid']
|
65
|
+
material.with_lock do # if this is an ActiveRecord, a lock is necessary to avoid race conditions
|
66
|
+
material.state.solid? # => true
|
67
|
+
material.state = 'liquid'
|
68
|
+
material.state # => 'liquid'
|
69
|
+
material.valid? # => true
|
70
|
+
|
71
|
+
# If the change is not valid, a validation error will be added to the object:
|
72
|
+
material.state.liquid? # => true
|
73
|
+
material.state = 'solid'
|
74
|
+
material.state # => 'solid'
|
75
|
+
material.valid? # => false
|
76
|
+
material.errors[:state] # => ['is invalid']
|
77
|
+
end
|
82
78
|
```
|
83
79
|
|
84
80
|
#### A Deliberate Design Choice
|
@@ -98,55 +94,27 @@ model.errors[:amount] # => ['must be greater than 0']
|
|
98
94
|
|
99
95
|
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
96
|
|
101
|
-
|
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:
|
97
|
+
### Saving Changes to State
|
104
98
|
|
105
|
-
|
106
|
-
|
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.
|
99
|
+
Commonly, state transition events are expected to have names, like "melt" and "evaporate," and other such _action verbs_.
|
100
|
+
SteadyState has no such expectation, and will not define any named events for you.
|
122
101
|
|
123
102
|
If you need them, we encourage you to define these transitions using plain ol' Ruby methods, like so:
|
124
103
|
|
125
104
|
```ruby
|
126
105
|
def melt
|
127
|
-
|
128
|
-
valid? # will return `false` if state transition is invalid
|
106
|
+
with_lock { update(state: 'liquid') }
|
129
107
|
end
|
130
108
|
|
131
109
|
def melt!
|
132
|
-
|
133
|
-
validate! # will raise an exception if state transition is invalid
|
110
|
+
with_lock { update!(state: 'liquid') }
|
134
111
|
end
|
135
112
|
```
|
136
113
|
|
137
|
-
|
138
|
-
|
139
|
-
```ruby
|
140
|
-
def melt
|
141
|
-
update(state: 'liquid')
|
142
|
-
end
|
143
|
-
|
144
|
-
def melt!
|
145
|
-
update!(state: 'liquid')
|
146
|
-
end
|
147
|
-
```
|
114
|
+
The use of `with_lock` is *strongly encouraged* in order to prevent race conditions that might result in invalid state transitions.
|
148
115
|
|
149
|
-
|
116
|
+
This is especially important for operations with side-effects, as a transactional lock will both prevent race conditions and guarantee an atomic rollback
|
117
|
+
if anything raises an exception:
|
150
118
|
|
151
119
|
```ruby
|
152
120
|
def melt
|
@@ -178,7 +146,7 @@ class MaterialsController < ApplicationController
|
|
178
146
|
end
|
179
147
|
```
|
180
148
|
|
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
|
149
|
+
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
150
|
|
183
151
|
## Addional Features & Configuration
|
184
152
|
|
@@ -207,6 +175,24 @@ material.status.solid? # => true
|
|
207
175
|
material.status.liquid? # => false
|
208
176
|
```
|
209
177
|
|
178
|
+
### Custom Validations
|
179
|
+
|
180
|
+
Using the supplied predicate methods, you can define your own validations that take effect only when the object enters a specific state:
|
181
|
+
|
182
|
+
```ruby
|
183
|
+
validates :container, absence: true, if: :solid?
|
184
|
+
validates :container, presence: true, if: :liquid?
|
185
|
+
```
|
186
|
+
|
187
|
+
With such a validation in place, a state change will not be valid unless the related validation rules are resolved at the same time:
|
188
|
+
|
189
|
+
```ruby
|
190
|
+
object.update!(state: 'liquid') # !! ActiveRecord::RecordInvalid
|
191
|
+
object.update!(state: 'liquid', container: Cup.new) # 🎉
|
192
|
+
```
|
193
|
+
|
194
|
+
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.
|
195
|
+
|
210
196
|
### Scopes
|
211
197
|
|
212
198
|
On ActiveRecord objects, scopes are automatically defined for each state:
|
@@ -245,6 +231,33 @@ As it stands, state history is not preserved, but it is still possible to get a
|
|
245
231
|
material.state.previous_values # => ['solid']
|
246
232
|
```
|
247
233
|
|
234
|
+
### ActiveModel Support
|
235
|
+
|
236
|
+
SteadyState is also available to classes that are not database-backed, as long as they include the `ActiveModel::Model` mixin:
|
237
|
+
|
238
|
+
```ruby
|
239
|
+
class Material
|
240
|
+
include ActiveModel::Model
|
241
|
+
|
242
|
+
attr_accessor :state
|
243
|
+
|
244
|
+
steady_state :state do
|
245
|
+
state 'solid', default: true
|
246
|
+
state 'liquid', from: 'solid'
|
247
|
+
end
|
248
|
+
|
249
|
+
def melt
|
250
|
+
self.state = 'liquid'
|
251
|
+
valid? # will return `false` if state transition is invalid
|
252
|
+
end
|
253
|
+
|
254
|
+
def melt!
|
255
|
+
self.state = 'liquid'
|
256
|
+
validate! # will raise an exception if state transition is invalid
|
257
|
+
end
|
258
|
+
end
|
259
|
+
```
|
260
|
+
|
248
261
|
## How to Contribute
|
249
262
|
|
250
263
|
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.
|
@@ -13,7 +13,7 @@ module SteadyState
|
|
13
13
|
end
|
14
14
|
|
15
15
|
class_methods do
|
16
|
-
def steady_state(attr_name, predicates: true, scopes: SteadyState.active_record?(self), &block) # rubocop:disable Metrics/
|
16
|
+
def steady_state(attr_name, predicates: true, states_getter: true, scopes: SteadyState.active_record?(self), &block) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/LineLength
|
17
17
|
overrides = Module.new do
|
18
18
|
define_method :"validate_#{attr_name}_transition_to" do |next_value|
|
19
19
|
if public_send(attr_name).may_become?(next_value)
|
@@ -43,6 +43,15 @@ module SteadyState
|
|
43
43
|
prepend overrides
|
44
44
|
|
45
45
|
state_machines[attr_name].instance_eval(&block)
|
46
|
+
|
47
|
+
if states_getter
|
48
|
+
cattr_reader(:"#{attr_name.to_s.pluralize}") do
|
49
|
+
state_machines[attr_name].states.map do |state|
|
50
|
+
State.new(state_machines[attr_name], state, nil)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
46
55
|
delegate(*state_machines[attr_name].predicates, to: attr_name, allow_nil: true) if predicates
|
47
56
|
if scopes
|
48
57
|
state_machines[attr_name].states.each do |state|
|
data/lib/steady_state/version.rb
CHANGED
@@ -290,6 +290,39 @@ RSpec.describe SteadyState::Attribute do
|
|
290
290
|
end
|
291
291
|
end
|
292
292
|
|
293
|
+
context 'with the states_getter 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
|
+
steady_state :car, options do
|
302
|
+
state 'driving', default: true
|
303
|
+
state 'stopped', from: 'driving'
|
304
|
+
state 'parked', from: 'stopped'
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
context 'default' do
|
310
|
+
let(:opts) { {} }
|
311
|
+
|
312
|
+
it 'defines states getter method' do
|
313
|
+
expect(steady_state_class.cars).to eq %w(driving stopped parked)
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
context 'disabled' do
|
318
|
+
let(:opts) { { states_getter: false } }
|
319
|
+
|
320
|
+
it 'does not define states getter method' do
|
321
|
+
expect { steady_state_class.cars }.to raise_error(NoMethodError, /undefined method `cars'/)
|
322
|
+
end
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
293
326
|
context 'with the scopes option' do
|
294
327
|
let(:query_object) { double(where: []) } # rubocop:disable RSpec/VerifiedDoubles
|
295
328
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: steady_state
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nathan Griffith
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2022-02-10 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activemodel
|
@@ -119,8 +119,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
119
119
|
- !ruby/object:Gem::Version
|
120
120
|
version: '0'
|
121
121
|
requirements: []
|
122
|
-
|
123
|
-
rubygems_version: 2.7.7
|
122
|
+
rubygems_version: 3.3.7
|
124
123
|
signing_key:
|
125
124
|
specification_version: 4
|
126
125
|
summary: Minimalist state management via "an enum with guard rails"
|