steady_state 0.0.1 → 0.1.0
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 +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"
|