statesmin 1.0.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 +7 -0
- data/.gitignore +18 -0
- data/.rubocop.yml +48 -0
- data/.travis.yml +15 -0
- data/CONTRIBUTING.md +28 -0
- data/Gemfile +3 -0
- data/Guardfile +14 -0
- data/LICENSE.txt +22 -0
- data/README.md +556 -0
- data/Rakefile +6 -0
- data/lib/statesmin.rb +8 -0
- data/lib/statesmin/callback.rb +52 -0
- data/lib/statesmin/exceptions.rb +21 -0
- data/lib/statesmin/guard.rb +13 -0
- data/lib/statesmin/machine.rb +276 -0
- data/lib/statesmin/railtie.rb +5 -0
- data/lib/statesmin/transition_helper.rb +41 -0
- data/lib/statesmin/version.rb +3 -0
- data/spec/spec_helper.rb +11 -0
- data/spec/statesmin/callback_spec.rb +120 -0
- data/spec/statesmin/guard_spec.rb +22 -0
- data/spec/statesmin/machine_spec.rb +704 -0
- data/spec/statesmin/transition_helper_spec.rb +170 -0
- data/statesmin.gemspec +26 -0
- metadata +142 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 91c3034b9d8315bcdd2dbe906b00982dae1db372
|
4
|
+
data.tar.gz: 4c20e9f7573b7f97022995dd0826b16373895036
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 25f7b623da7287071cbf5da621458723ded93c17be418573d8916b68916c0c6d9e82fe0e8b5f837d1b6c65c66096e0dad2886385c06f7201c60dc689f94b3d71
|
7
|
+
data.tar.gz: 007baf17484bc3479115a40fe98bcf7a7811a0f6eefce3081a89fe93f69f134c109b555b9833d23900fcd80a82f387fd0b0aa22d1e325206df63bf406a7b6d57
|
data/.gitignore
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
# For all options see https://github.com/bbatsov/rubocop/tree/master/config
|
2
|
+
|
3
|
+
AllCops:
|
4
|
+
Include:
|
5
|
+
- Rakefile
|
6
|
+
- statesman.gemfile
|
7
|
+
- lib/tasks/*.rake
|
8
|
+
Exclude:
|
9
|
+
- vendor/**/*
|
10
|
+
- .*/**
|
11
|
+
- spec/fixtures/**/*
|
12
|
+
|
13
|
+
StringLiterals:
|
14
|
+
Enabled: false
|
15
|
+
|
16
|
+
Documentation:
|
17
|
+
Enabled: false
|
18
|
+
|
19
|
+
SignalException:
|
20
|
+
EnforcedStyle: only_raise
|
21
|
+
|
22
|
+
# Avoid methods longer than 15 lines of code
|
23
|
+
MethodLength:
|
24
|
+
CountComments: false
|
25
|
+
Max: 15
|
26
|
+
|
27
|
+
AbcSize:
|
28
|
+
Max: 25
|
29
|
+
|
30
|
+
# Don't require utf-8 encoding comment
|
31
|
+
Encoding:
|
32
|
+
Enabled: false
|
33
|
+
|
34
|
+
LineLength:
|
35
|
+
Max: 80
|
36
|
+
|
37
|
+
GuardClause:
|
38
|
+
Enabled: false
|
39
|
+
|
40
|
+
SingleSpaceBeforeFirstArg:
|
41
|
+
Enabled: false
|
42
|
+
|
43
|
+
DotPosition:
|
44
|
+
EnforcedStyle: trailing
|
45
|
+
|
46
|
+
# Allow class and message or instance raises
|
47
|
+
Style/RaiseArgs:
|
48
|
+
Enabled: false
|
data/.travis.yml
ADDED
data/CONTRIBUTING.md
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
Thanks for taking an interest in contributing to Statesmin, here are a few
|
2
|
+
ways you can help make this project better!
|
3
|
+
|
4
|
+
# Contribute.md
|
5
|
+
|
6
|
+
## Team members
|
7
|
+
|
8
|
+
- [Andy Appleton](https://twitter.com/appltn)
|
9
|
+
- [Harry Marr](https://twitter.com/harrymarr)
|
10
|
+
|
11
|
+
## Contributing
|
12
|
+
|
13
|
+
- Generally we welcome new features but please first open an issue where we
|
14
|
+
can discuss whether it fits with our vision for the project.
|
15
|
+
- Any new feature or bug fix needs an accompanying test case.
|
16
|
+
- No need to add to the changelog, we will take care of updating it as we make
|
17
|
+
releases.
|
18
|
+
|
19
|
+
## Style
|
20
|
+
|
21
|
+
We use [Rubocop](https://github.com/bbatsov/rubocop) to help maintain a
|
22
|
+
consistent code style across the project. Please check that your pull
|
23
|
+
request passes by running `rubocop`.
|
24
|
+
|
25
|
+
## Documentation
|
26
|
+
|
27
|
+
Please add a section to the readme for any new feature additions or behaviour
|
28
|
+
changes.
|
data/Gemfile
ADDED
data/Guardfile
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
# A sample Guardfile
|
2
|
+
# More info at https://github.com/guard/guard#readme
|
3
|
+
|
4
|
+
guard :rspec, all_on_start: true, cmd: 'bundle exec rspec --color' do
|
5
|
+
watch(%r{^spec/.+_spec\.rb$})
|
6
|
+
watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
|
7
|
+
watch('spec/spec_helper.rb') { "spec" }
|
8
|
+
end
|
9
|
+
|
10
|
+
guard :rubocop, all_on_start: true, cli: ['--format', 'clang'] do
|
11
|
+
watch(%r{.+\.rb$})
|
12
|
+
watch(%r{(?:.+/)?\.rubocop\.yml$}) { |m| File.dirname(m[0]) }
|
13
|
+
watch(%r{(?:.+/)?\rubocop-todo\.yml$}) { |m| File.dirname(m[0]) }
|
14
|
+
end
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 Harry Marr
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,556 @@
|
|
1
|
+
# Statesmin
|
2
|
+
|
3
|
+
[](https://travis-ci.org/mkcode/statesmin)
|
4
|
+
|
5
|
+
Statesmin is a fork of [stateman](https://github.com/gocardless/statesman) that
|
6
|
+
uses a machete to rip out all of the database related code leaving you with a
|
7
|
+
simple, robust, and well tested DSL for defining state machines in your
|
8
|
+
application.
|
9
|
+
|
10
|
+
### When to use statesmin over statesman:
|
11
|
+
|
12
|
+
* You wish to manage an object's current state yourself, including not
|
13
|
+
persisting it at all.
|
14
|
+
* You have custom requirements for your transition log entries.
|
15
|
+
* You need multiple (and very different) transition processes.
|
16
|
+
* You enjoy and habitually write service objects with small scopes.
|
17
|
+
* You will be frequently updating the state of an object and you can expect the
|
18
|
+
transitions log to contain a lot of entries.
|
19
|
+
|
20
|
+
If any of the above apply to your application, then consider using statesmin. In
|
21
|
+
addition to defining your state machines, statesmin also requires you to:
|
22
|
+
|
23
|
+
* Persist the current state of the object(s) yourself.
|
24
|
+
* Instantiate a state machine with the object's current state yourself.
|
25
|
+
* Maintain an transition / audit log yourself (if required)
|
26
|
+
* Define a custom transition process yourself.
|
27
|
+
|
28
|
+
All in all, statesmin takes considerably more work to get setup and running than
|
29
|
+
statesman, so statesman is recommended if you need to get a state machine setup
|
30
|
+
and running without any special requirements or concerns.
|
31
|
+
|
32
|
+
### Working with Statesmin::Machine
|
33
|
+
|
34
|
+
Defining a state machine uses the same DSL as statesman. See
|
35
|
+
[tldr-usage](https://github.com/mkcode/statesmin#tldr-usage) for a more complete
|
36
|
+
example.
|
37
|
+
|
38
|
+
```ruby
|
39
|
+
class OrderStateMachine
|
40
|
+
include Statesmin::Machine
|
41
|
+
|
42
|
+
state :pending, initial: true
|
43
|
+
state :checking_out
|
44
|
+
state :purchased
|
45
|
+
state :cancelled
|
46
|
+
|
47
|
+
transition from: :pending, to: [:checking_out, :cancelled]
|
48
|
+
transition from: :checking_out, to: [:purchased, :cancelled]
|
49
|
+
|
50
|
+
guard_transition(to: :checking_out) do |order|
|
51
|
+
order.products_in_stock?
|
52
|
+
end
|
53
|
+
|
54
|
+
before_transition(from: :checking_out, to: :cancelled) do |order, transition|
|
55
|
+
order.reallocate_stock
|
56
|
+
end
|
57
|
+
|
58
|
+
after_transition(to: :purchased) do |order, transition|
|
59
|
+
MailerService.order_confirmation(order).deliver
|
60
|
+
end
|
61
|
+
end
|
62
|
+
```
|
63
|
+
|
64
|
+
### Instantiating a Statesmin::Machine
|
65
|
+
|
66
|
+
The `Statesman::Machine` instance initializer now takes a `state` option which
|
67
|
+
sets the initial state of the state machine. If the `state` option is omitted,
|
68
|
+
the `initial: true` state from the Machine definition is used. Passing an
|
69
|
+
invalid state will yield a `Statesmin::InvalidStateError`.
|
70
|
+
|
71
|
+
```ruby
|
72
|
+
# A valid state is set as the current_state
|
73
|
+
state_machine = OrderStateMachine.new(Order.first, state: :cancelled)
|
74
|
+
state_machine.current_state # => "cancelled"
|
75
|
+
|
76
|
+
# Invalid states raise an InvaliedStateError
|
77
|
+
state_machine = OrderStateMachine.new(Order.first, state: :whoops)
|
78
|
+
# => raise Statesmin::InvalidStateError
|
79
|
+
|
80
|
+
# No state option sets the state to the initial state
|
81
|
+
state_machine = OrderStateMachine.new(Order.first)
|
82
|
+
state_machine.current_state # => "pending"
|
83
|
+
```
|
84
|
+
|
85
|
+
### Statesmin::Machine instance methods
|
86
|
+
|
87
|
+
All instance methods from statesman are available on statesmin with the
|
88
|
+
exception of `#history` and `#last_transition`.
|
89
|
+
|
90
|
+
```ruby
|
91
|
+
state_machine = OrderStateMachine.new(Order.first)
|
92
|
+
state_machine.current_state # => "pending"
|
93
|
+
state_machine.in_state?(:failed, :cancelled) # => true/false
|
94
|
+
state_machine.allowed_transitions # => ["checking_out", "cancelled"]
|
95
|
+
state_machine.can_transition_to?(:cancelled) # => true/false
|
96
|
+
```
|
97
|
+
|
98
|
+
The `#transition_to` and `#transition_to!` methods are updated. They now simply
|
99
|
+
update the state machines internal current state to the new state when it is
|
100
|
+
valid. `transition_to!` raises a `Statesmin::TransitionFailedError` when an
|
101
|
+
invalid state is given. `transition_to` returns false.
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
state_machine = OrderStateMachine.new(Order.first, state: :pending)
|
105
|
+
state_machine.current_state # => "pending"
|
106
|
+
|
107
|
+
state_machine.transition_to!(:invalid_state)
|
108
|
+
# => raise Statesmin::TransitionFailedError
|
109
|
+
|
110
|
+
state_machine.transition_to(:invalid_state)
|
111
|
+
# => false
|
112
|
+
state_machine.current_state # => "pending"
|
113
|
+
|
114
|
+
state_machine.transition_to!(:checking_out) # => true
|
115
|
+
state_machine.current_state # => "checking_out"
|
116
|
+
```
|
117
|
+
|
118
|
+
### Statesmin::Machine #transition_to! and #transition_to
|
119
|
+
|
120
|
+
The `#transition_to` and `#transition_to!` methods now both take a block
|
121
|
+
argument as well. If a block is given, any error raised in the block body will
|
122
|
+
halt the transition and not update the current state. `transition_to!` will
|
123
|
+
always raise the error from the block body, while `transition_to` will return
|
124
|
+
false if a `Statesmin::TransitionFailedError` is raised. `transition_to` will
|
125
|
+
still raise all other errors.
|
126
|
+
|
127
|
+
`#transition_to` and `#transition_to!` will both return the value returned from
|
128
|
+
the block when they are called without errors. The state machine's current state
|
129
|
+
is updated to the new state immediately after the block has executed.
|
130
|
+
|
131
|
+
Finally, `#transition_to` and `#transition_to!` will only execute the given
|
132
|
+
block if the state argument is a valid transition. Invalid state arguments will
|
133
|
+
behave the same way as they do without blocks, either returning false or raising
|
134
|
+
a `Statesmin::TransitionFailedError` respectively.
|
135
|
+
|
136
|
+
```ruby
|
137
|
+
state_machine = OrderStateMachine.new(Order.first, state: :pending)
|
138
|
+
state_machine.current_state # => "pending"
|
139
|
+
|
140
|
+
state_machine.transition_to! :invalid_state do
|
141
|
+
puts 'never evaluated due to the :invalid_state argument'
|
142
|
+
end
|
143
|
+
# => raise Statesmin::TransitionFailedError
|
144
|
+
|
145
|
+
state_machine.transition_to :checking_out do
|
146
|
+
raise Statesmin::TransitionFailedError
|
147
|
+
end
|
148
|
+
# => false
|
149
|
+
|
150
|
+
state_machine.transition_to :checking_out do
|
151
|
+
raise Order::InvalidAddress
|
152
|
+
end
|
153
|
+
# => raise Order::InvalidAddress
|
154
|
+
state_machine.current_state # => "pending"
|
155
|
+
|
156
|
+
state_machine.transition_to :checking_out do
|
157
|
+
OrderLogEntry.create!(order_data)
|
158
|
+
end
|
159
|
+
# => <#OrderLogEntry>
|
160
|
+
state_machine.current_state # => "checking_out
|
161
|
+
```
|
162
|
+
|
163
|
+
The transition block is the basis of how Statesmin allows for custom transition
|
164
|
+
behavior and distinguishes itself from Statesman. For small application or
|
165
|
+
transition requirements, the transition block may be sufficient but in most
|
166
|
+
cases defining a Transition class is recommended.
|
167
|
+
|
168
|
+
### Defining a Transition class
|
169
|
+
|
170
|
+
You are free to set up a state machine and corresponding transition behavior
|
171
|
+
however you like. The `TransitionHelper` module is included to help provide
|
172
|
+
structure and reduce boilerplate code.
|
173
|
+
|
174
|
+
Create a new class which includes the `Statesmin::TransitionHelper` module. This
|
175
|
+
module does the following for you:
|
176
|
+
|
177
|
+
* Sets up a good outline for a Transaction (service) class
|
178
|
+
* Delegates reader methods to an underlying state machine instance
|
179
|
+
* Intercepts transition methods so they may be extend with specific behavior
|
180
|
+
|
181
|
+
`Statesmin::TransitionHelper` requires you to define two methods in your
|
182
|
+
transition class:
|
183
|
+
|
184
|
+
* `state_machine` - This method returns the instance of the
|
185
|
+
`Statesmin::Machine` class to use in the class. The reader methods delegate
|
186
|
+
to this state machine instance. You will most likely also need it in other
|
187
|
+
methods.
|
188
|
+
|
189
|
+
* `transition` - This method defines the custom portion of the transition logic
|
190
|
+
for this application and object. Usually, you will trigger state persistence,
|
191
|
+
Transition logging, and callback execution from this method. Multiple
|
192
|
+
database updates are always recommended to be wrapped in a transaction.
|
193
|
+
|
194
|
+
#### Example
|
195
|
+
|
196
|
+
The following example does the following during a transition:
|
197
|
+
|
198
|
+
* Builds and saves an OrderLog record to the OrderLog table
|
199
|
+
* Persists the current state of the order in the Order table.
|
200
|
+
* Executes any before, after, and after_commit callbacks for the specific
|
201
|
+
transition
|
202
|
+
* Commits all of these database updates atomically (everything or nothing)
|
203
|
+
* Returns the newly created order log record.
|
204
|
+
|
205
|
+
```ruby
|
206
|
+
class OrderTransitionService
|
207
|
+
include Statesmin::TransitionHelper
|
208
|
+
|
209
|
+
def initialize(order)
|
210
|
+
@order = order
|
211
|
+
end
|
212
|
+
|
213
|
+
private
|
214
|
+
|
215
|
+
def transition(next_state, data = {})
|
216
|
+
order_log = build_order_log_entry(next_state, data)
|
217
|
+
|
218
|
+
::ActiveRecord::Base.transaction do
|
219
|
+
state_machine.execute(:before, current_state, next_state, data)
|
220
|
+
@order.update!(state: next_state)
|
221
|
+
order_log.save!
|
222
|
+
state_machine.execute(:after, current_state, next_state, data)
|
223
|
+
end
|
224
|
+
state_machine.execute(:after_commit, current_state, next_state, data)
|
225
|
+
|
226
|
+
order_log
|
227
|
+
end
|
228
|
+
|
229
|
+
def state_machine
|
230
|
+
@state_machine ||= OrderStateMachine.new(@order, state: @order.state)
|
231
|
+
end
|
232
|
+
|
233
|
+
def build_order_log_entry(next_state, data)
|
234
|
+
log_attributes = { from: current_state, to: next_state, data: data }
|
235
|
+
@order.order_logs.build(log_attributes)
|
236
|
+
end
|
237
|
+
end
|
238
|
+
```
|
239
|
+
|
240
|
+
An instance of OrderTransitionService now has the same methods as
|
241
|
+
`Statesmin::Machine`.
|
242
|
+
|
243
|
+
```ruby
|
244
|
+
order_transition = OrderTransitionService.new(Order.first)
|
245
|
+
|
246
|
+
# reader methods are delegated to `state_machine`
|
247
|
+
order_transition.current_state # => "pending"
|
248
|
+
order_transition.in_state?(:failed, :cancelled) # => true/false
|
249
|
+
order_transition.allowed_transitions # => ["checking_out", "cancelled"]
|
250
|
+
order_transition.can_transition_to?(:cancelled) # => true/false
|
251
|
+
|
252
|
+
# `transition_to` and `transition_to!` also execute the transition method
|
253
|
+
order_transition.transition_to(:invalid_state)
|
254
|
+
# => false
|
255
|
+
order_transition.current_state # => "pending"
|
256
|
+
|
257
|
+
order_transition.transition_to!(:checking_out)
|
258
|
+
# => <#OrderLogEntry>
|
259
|
+
order_transition.current_state # => "checking_out"
|
260
|
+
```
|
261
|
+
|
262
|
+
### Flexibility
|
263
|
+
|
264
|
+
The above example defines behavior similar to Statesman. Some examples of what
|
265
|
+
else can be done with an open Transition class.
|
266
|
+
|
267
|
+
* Have multiple state machines for the same object by adding a condition in the
|
268
|
+
`states_machine` method.
|
269
|
+
* Have multiple types a transitions for the same object by defining multiple
|
270
|
+
Transition classes with the same instantiating object.
|
271
|
+
* Have different Transition logs/tables for different objects.
|
272
|
+
* Turn parts of a transition on and off based off of an initializer argument
|
273
|
+
|
274
|
+
|
275
|
+
The following is an adapted version of the original Statesman README.
|
276
|
+
|
277
|
+
---
|
278
|
+
|
279
|
+

|
280
|
+
|
281
|
+
A statesmanlike state machine library for Ruby 2.0.0 and up.
|
282
|
+
|
283
|
+
Statesmin is an opinionated state machine library designed to provide a robust
|
284
|
+
audit trail and data integrity. It decouples the state machine logic from the
|
285
|
+
underlying model and allows for easy composition with one or more model classes.
|
286
|
+
|
287
|
+
As such, the design of statesman is a little different from other state machine
|
288
|
+
libraries:
|
289
|
+
- State behaviour is defined in a separate, "state machine" class, rather than
|
290
|
+
added directly onto a model. State machines are then instantiated with the model
|
291
|
+
to which they should apply.
|
292
|
+
- ~~State transitions are also modelled as a class, which can optionally be
|
293
|
+
persisted to the database for a full audit history. This audit history can
|
294
|
+
include JSON metadata set during a transition.~~
|
295
|
+
- ~~Database indices are used to offer database-level transaction duplication
|
296
|
+
protection.~~
|
297
|
+
- Free to define your own transition logic for your application!
|
298
|
+
|
299
|
+
## TL;DR Usage
|
300
|
+
|
301
|
+
```ruby
|
302
|
+
|
303
|
+
#######################
|
304
|
+
# State Machine Class #
|
305
|
+
#######################
|
306
|
+
class OrderStateMachine
|
307
|
+
include Statesmin::Machine
|
308
|
+
|
309
|
+
state :pending, initial: true
|
310
|
+
state :checking_out
|
311
|
+
state :purchased
|
312
|
+
state :shipped
|
313
|
+
state :cancelled
|
314
|
+
state :failed
|
315
|
+
state :refunded
|
316
|
+
|
317
|
+
transition from: :pending, to: [:checking_out, :cancelled]
|
318
|
+
transition from: :checking_out, to: [:purchased, :cancelled]
|
319
|
+
transition from: :purchased, to: [:shipped, :failed]
|
320
|
+
transition from: :shipped, to: :refunded
|
321
|
+
|
322
|
+
guard_transition(to: :checking_out) do |order|
|
323
|
+
order.products_in_stock?
|
324
|
+
end
|
325
|
+
|
326
|
+
before_transition(from: :checking_out, to: :cancelled) do |order, transition|
|
327
|
+
order.reallocate_stock
|
328
|
+
end
|
329
|
+
|
330
|
+
before_transition(to: :purchased) do |order, transition|
|
331
|
+
PaymentService.new(order).submit
|
332
|
+
end
|
333
|
+
|
334
|
+
after_transition(to: :purchased) do |order, transition|
|
335
|
+
MailerService.order_confirmation(order).deliver
|
336
|
+
end
|
337
|
+
end
|
338
|
+
|
339
|
+
##############
|
340
|
+
# Your Model #
|
341
|
+
##############
|
342
|
+
class Order < ActiveRecord::Base
|
343
|
+
include Statesmin::Adapters::ActiveRecordQueries
|
344
|
+
|
345
|
+
has_many :order_transitions, autosave: false
|
346
|
+
|
347
|
+
def state_machine
|
348
|
+
@state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition)
|
349
|
+
end
|
350
|
+
|
351
|
+
def self.transition_class
|
352
|
+
OrderTransition
|
353
|
+
end
|
354
|
+
private_class_method :transition_class
|
355
|
+
|
356
|
+
def self.initial_state
|
357
|
+
:pending
|
358
|
+
end
|
359
|
+
private_class_method :initial_state
|
360
|
+
end
|
361
|
+
|
362
|
+
####################
|
363
|
+
# Transition Model #
|
364
|
+
####################
|
365
|
+
class OrderTransition < ActiveRecord::Base
|
366
|
+
include Statesmin::Adapters::ActiveRecordTransition
|
367
|
+
|
368
|
+
belongs_to :order, inverse_of: :order_transitions
|
369
|
+
end
|
370
|
+
|
371
|
+
########################
|
372
|
+
# Example method calls #
|
373
|
+
########################
|
374
|
+
Order.first.state_machine.current_state # => "pending"
|
375
|
+
Order.first.state_machine.allowed_transitions # => ["checking_out", "cancelled"]
|
376
|
+
Order.first.state_machine.can_transition_to?(:cancelled) # => true/false
|
377
|
+
Order.first.state_machine.transition_to(:cancelled, optional: :metadata) # => true/false
|
378
|
+
Order.first.state_machine.transition_to!(:cancelled) # => true/exception
|
379
|
+
|
380
|
+
Order.in_state(:cancelled) # => [#<Order id: "123">]
|
381
|
+
Order.not_in_state(:checking_out) # => [#<Order id: "123">]
|
382
|
+
|
383
|
+
```
|
384
|
+
|
385
|
+
|
386
|
+
## Class methods
|
387
|
+
|
388
|
+
#### `Machine.state`
|
389
|
+
```ruby
|
390
|
+
Machine.state(:some_state, initial: true)
|
391
|
+
Machine.state(:another_state)
|
392
|
+
```
|
393
|
+
Define a new state and optionally mark as the initial state.
|
394
|
+
|
395
|
+
#### `Machine.transition`
|
396
|
+
```ruby
|
397
|
+
Machine.transition(from: :some_state, to: :another_state)
|
398
|
+
```
|
399
|
+
Define a transition rule. Both method parameters are required, `to` can also be
|
400
|
+
an array of states (`.transition(from: :some_state, to: [:another_state, :some_other_state])`).
|
401
|
+
|
402
|
+
#### `Machine.guard_transition`
|
403
|
+
```ruby
|
404
|
+
Machine.guard_transition(from: :some_state, to: :another_state) do |object|
|
405
|
+
object.some_boolean?
|
406
|
+
end
|
407
|
+
```
|
408
|
+
Define a guard. `to` and `from` parameters are optional, a nil parameter means
|
409
|
+
guard all transitions. The passed block should evaluate to a boolean and must
|
410
|
+
be idempotent as it could be called many times.
|
411
|
+
|
412
|
+
#### `Machine.before_transition`
|
413
|
+
```ruby
|
414
|
+
Machine.before_transition(from: :some_state, to: :another_state) do |object|
|
415
|
+
object.side_effect
|
416
|
+
end
|
417
|
+
```
|
418
|
+
Define a callback to run before a transition. `to` and `from` parameters are
|
419
|
+
optional, a nil parameter means run before all transitions. This callback can
|
420
|
+
have side-effects as it will only be run once immediately before the transition.
|
421
|
+
|
422
|
+
#### `Machine.after_transition`
|
423
|
+
```ruby
|
424
|
+
Machine.after_transition(from: :some_state, to: :another_state) do |object, transition|
|
425
|
+
object.side_effect
|
426
|
+
end
|
427
|
+
```
|
428
|
+
Define a callback to run after a successful transition. `to` and `from`
|
429
|
+
parameters are optional, a nil parameter means run after all transitions. The
|
430
|
+
model object and transition object are passed as arguments to the callback.
|
431
|
+
This callback can have side-effects as it will only be run once immediately
|
432
|
+
after the transition.
|
433
|
+
|
434
|
+
If you specify `after_commit: true`, the callback will be executed once the
|
435
|
+
transition has been committed to the database.
|
436
|
+
|
437
|
+
#### `Machine.new`
|
438
|
+
```ruby
|
439
|
+
my_machine = Machine.new(my_model)
|
440
|
+
```
|
441
|
+
Initialize a new state machine instance. `my_model` is required.
|
442
|
+
|
443
|
+
#### `Machine.retry_conflicts`
|
444
|
+
```ruby
|
445
|
+
Machine.retry_conflicts { instance.transition_to(:new_state) }
|
446
|
+
```
|
447
|
+
Automatically retry the given block if a `TransitionConflictError` is raised.
|
448
|
+
If you know you want to retry a transition if it fails due to a race condition
|
449
|
+
call it from within this block. Takes an (optional) argument for the maximum
|
450
|
+
number of retry attempts (defaults to 1).
|
451
|
+
|
452
|
+
## Instance methods
|
453
|
+
|
454
|
+
#### `Machine#current_state`
|
455
|
+
Returns the current state based on existing transition objects.
|
456
|
+
|
457
|
+
#### `Machine#in_state?(:state_1, :state_2, ...)`
|
458
|
+
Returns true if the machine is in any of the given states.
|
459
|
+
|
460
|
+
#### `Machine#allowed_transitions`
|
461
|
+
Returns an array of states you can `transition_to` from current state.
|
462
|
+
|
463
|
+
#### `Machine#can_transition_to?(:state)`
|
464
|
+
Returns true if the current state can transition to the passed state and all
|
465
|
+
applicable guards pass.
|
466
|
+
|
467
|
+
#### `Machine#transition_to!(:state)`
|
468
|
+
Transition to the passed state, returning `true` on success. Raises
|
469
|
+
`Statesmin::GuardFailedError` or `Statesmin::TransitionFailedError` on failure.
|
470
|
+
|
471
|
+
#### `Machine#transition_to(:state)`
|
472
|
+
Transition to the passed state, returning `true` on success. Swallows all
|
473
|
+
Statesmin exceptions and returns false on failure. (NB. if your guard or
|
474
|
+
callback code throws an exception, it will not be caught.)
|
475
|
+
|
476
|
+
## Frequently Asked Questions
|
477
|
+
|
478
|
+
#### Storing the state on the model object
|
479
|
+
|
480
|
+
If you wish to store the model state on the model directly, you can keep it up
|
481
|
+
to date using an `after_transition` hook:
|
482
|
+
|
483
|
+
```ruby
|
484
|
+
after_transition do |model, transition|
|
485
|
+
model.state = transition.to_state
|
486
|
+
model.save!
|
487
|
+
end
|
488
|
+
```
|
489
|
+
|
490
|
+
You could also use a calculated column or view in your database.
|
491
|
+
|
492
|
+
#### Accessing metadata from the last transition
|
493
|
+
|
494
|
+
Given a field `foo` that was stored in the metadata, you can access it like so:
|
495
|
+
|
496
|
+
```ruby
|
497
|
+
model_instance.last_transition.metadata["foo"]
|
498
|
+
```
|
499
|
+
|
500
|
+
#### Events
|
501
|
+
|
502
|
+
Used to using a state machine with "events"? Support for events is provided by
|
503
|
+
the [statesman-events](https://github.com/gocardless/statesman-events) gem. Once
|
504
|
+
that's included in your Gemfile you can include event functionality in your
|
505
|
+
state machine as follows:
|
506
|
+
|
507
|
+
```ruby
|
508
|
+
class OrderStateMachine
|
509
|
+
include Statesmin::Machine
|
510
|
+
include Statesmin::Events
|
511
|
+
|
512
|
+
...
|
513
|
+
end
|
514
|
+
```
|
515
|
+
|
516
|
+
## Testing Statesmin Implementations
|
517
|
+
|
518
|
+
This answer was abstracted from [this issue](https://github.com/gocardless/statesman/issues/77).
|
519
|
+
|
520
|
+
At GoCardless we focus on testing that:
|
521
|
+
- guards correctly prevent / allow transitions
|
522
|
+
- callbacks execute when expected and perform the expected actions
|
523
|
+
|
524
|
+
#### Testing Guards
|
525
|
+
|
526
|
+
Guards can be tested by asserting that `transition_to!` does or does not raise a `Statesmin::GuardFailedError`:
|
527
|
+
|
528
|
+
```ruby
|
529
|
+
describe "guards" do
|
530
|
+
it "cannot transition from state foo to state bar" do
|
531
|
+
expect { some_model.transition_to!(:bar) }.to raise_error(Statesmin::GuardFailedError)
|
532
|
+
end
|
533
|
+
|
534
|
+
it "can transition from state foo to state baz" do
|
535
|
+
expect { some_model.transition_to!(:baz) }.to_not raise_error
|
536
|
+
end
|
537
|
+
end
|
538
|
+
```
|
539
|
+
|
540
|
+
#### Testing Callbacks
|
541
|
+
|
542
|
+
Callbacks are tested by asserting that the action they perform occurs:
|
543
|
+
|
544
|
+
```ruby
|
545
|
+
describe "some callback" do
|
546
|
+
it "adds one to the count property on the model" do
|
547
|
+
expect { some_model.transition_to!(:some_state) }.
|
548
|
+
to change { some_model.reload.count }.
|
549
|
+
by(1)
|
550
|
+
end
|
551
|
+
end
|
552
|
+
```
|
553
|
+
|
554
|
+
---
|
555
|
+
|
556
|
+
GoCardless ♥ open source. If you do too, come [join us](https://gocardless.com/jobs#software-engineer).
|