statesman 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/.gitignore +17 -0
- data/.rubocop.yml +20 -0
- data/Gemfile +4 -0
- data/Guardfile +14 -0
- data/LICENSE.txt +22 -0
- data/README.md +193 -0
- data/Rakefile +1 -0
- data/circle.yml +9 -0
- data/lib/generators/statesman/migration_generator.rb +35 -0
- data/lib/generators/statesman/templates/create_migration.rb.erb +13 -0
- data/lib/generators/statesman/templates/transition_model.rb.erb +4 -0
- data/lib/generators/statesman/templates/update_migration.rb.erb +11 -0
- data/lib/generators/statesman/transition_generator.rb +39 -0
- data/lib/statesman.rb +24 -0
- data/lib/statesman/adapters/active_record.rb +53 -0
- data/lib/statesman/adapters/memory.rb +39 -0
- data/lib/statesman/callback.rb +34 -0
- data/lib/statesman/config.rb +23 -0
- data/lib/statesman/exceptions.rb +8 -0
- data/lib/statesman/guard.rb +15 -0
- data/lib/statesman/machine.rb +231 -0
- data/lib/statesman/transition.rb +15 -0
- data/lib/statesman/version.rb +3 -0
- data/spec/spec_helper.rb +33 -0
- data/spec/statesman/adapters/active_record_spec.rb +50 -0
- data/spec/statesman/adapters/memory_spec.rb +7 -0
- data/spec/statesman/adapters/shared_examples.rb +111 -0
- data/spec/statesman/callback_spec.rb +119 -0
- data/spec/statesman/config_spec.rb +34 -0
- data/spec/statesman/guard_spec.rb +28 -0
- data/spec/statesman/machine_spec.rb +459 -0
- data/spec/statesman/transition_spec.rb +20 -0
- data/spec/support/active_record.rb +35 -0
- data/statesman.gemspec +29 -0
- metadata +201 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 76cf9c150c6ff65db2ce679c9894d577a97d0d1a
|
4
|
+
data.tar.gz: 9b5726a9af39ca123b63acea82970286debd2dd2
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 23f5b5f680675e7b58f573295c9fc30aa00a150df0190afcbd0392b4b56cf0ebe90cef0405f07dc30b9d9c7ab574f5533e88a382d4d5548486906252356620f3
|
7
|
+
data.tar.gz: 6869fd521815f23be8e74575e57c83dba70b5b4c5d036c2d0ba0321463c2e27ff6dd46f13f4e524f9e18219b80f95623aab50089a5194a36b901723b5eb65426
|
data/.gitignore
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# For all options see https://github.com/bbatsov/rubocop/tree/master/config
|
2
|
+
|
3
|
+
AllCops:
|
4
|
+
Includes:
|
5
|
+
- Rakefile
|
6
|
+
- statesman.gemfile
|
7
|
+
Excludes:
|
8
|
+
- vendor/**
|
9
|
+
|
10
|
+
StringLiterals:
|
11
|
+
Enabled: false
|
12
|
+
|
13
|
+
Documentation:
|
14
|
+
Enabled: false
|
15
|
+
|
16
|
+
# Avoid methods longer than 30 lines of code
|
17
|
+
MethodLength:
|
18
|
+
CountComments: false # count full line comments?
|
19
|
+
Max: 15
|
20
|
+
|
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, cli: "--color", all_on_start: true 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,193 @@
|
|
1
|
+

|
2
|
+
|
3
|
+
A statesmanlike state machine library for Ruby 2.0.
|
4
|
+
|
5
|
+
Statesman is a little different from other state machine libraries which tack state behaviour directly onto a model. A statesman state machine is defined as a separate class which is instantiated with the model to which it should apply. State transitions are also modelled as a class which can optionally be persisted to the database for a full audit history, including JSON metadata which can be set during a transition.
|
6
|
+
|
7
|
+
This data model allows for interesting things like using a different state machine depending on the value of a model attribute.
|
8
|
+
|
9
|
+
## TL;DR Usage
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
class OrderStateMachine
|
13
|
+
include Statesman::Machine
|
14
|
+
|
15
|
+
state :pending, initial: true
|
16
|
+
state :checking_out
|
17
|
+
state :purchased
|
18
|
+
state :shipped
|
19
|
+
state :cancelled
|
20
|
+
state :failed
|
21
|
+
state :refunded
|
22
|
+
|
23
|
+
transition from: :created, to: [:checking_out, :cancelled]
|
24
|
+
transition from: :checking_out, to: [:purchased, :cancelled]
|
25
|
+
transition from: :purchased, to: [:shipped, :failed]
|
26
|
+
transition from: :shipped, to: :refunded
|
27
|
+
|
28
|
+
guard_transition(to: :checking_out) do |order|
|
29
|
+
order.products_in_stock?
|
30
|
+
end
|
31
|
+
|
32
|
+
before_transition(from: :checking_out, to: :cancelled) do |order, transition|
|
33
|
+
order.reallocate_stock
|
34
|
+
end
|
35
|
+
|
36
|
+
before_transition(to: :purchased) do |order, transition|
|
37
|
+
PaymentService.new(order).submit
|
38
|
+
end
|
39
|
+
|
40
|
+
after_transition(to: :purchased) do |order, transition|
|
41
|
+
MailerService.order_confirmation(order).deliver
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
class Order < ActiveRecord::Base
|
46
|
+
has_many :order_transitions
|
47
|
+
|
48
|
+
def state_machine
|
49
|
+
OrderStateMachine.new(self, transition_class: OrderTransition)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
class OrderTransition < ActiveRecord::Base
|
54
|
+
belongs_to :order, inverse_of: :order_transitions
|
55
|
+
end
|
56
|
+
|
57
|
+
Order.first.state_machine.current_state
|
58
|
+
# => "created"
|
59
|
+
|
60
|
+
Order.first.state_machine.can_transition_to?(:cancelled)
|
61
|
+
# => true/false
|
62
|
+
|
63
|
+
Order.first.state_machine.transition_to(:cancelled, optional: :metadata)
|
64
|
+
# => true/false
|
65
|
+
|
66
|
+
Order.first.state_machine.transition_to!(:cancelled)
|
67
|
+
# => true/exception
|
68
|
+
```
|
69
|
+
|
70
|
+
## Persistence
|
71
|
+
|
72
|
+
By default Statesman stores transition history in memory only. It can be
|
73
|
+
persisted by configuring Statesman to use a different adapter. For example,
|
74
|
+
ActiveRecord within Rails:
|
75
|
+
|
76
|
+
`config/initializers/statesman.rb`:
|
77
|
+
|
78
|
+
```ruby
|
79
|
+
Statesman.configure do
|
80
|
+
storage_adapter(Statesman::Adapters::ActiveRecord)
|
81
|
+
transition_class(OrderTransition)
|
82
|
+
end
|
83
|
+
```
|
84
|
+
|
85
|
+
Generate the transition model:
|
86
|
+
|
87
|
+
```bash
|
88
|
+
$ rails g statesman:transition Order OrderTransition
|
89
|
+
```
|
90
|
+
|
91
|
+
And add an association from the parent model:
|
92
|
+
|
93
|
+
`app/models/order.rb`:
|
94
|
+
|
95
|
+
```ruby
|
96
|
+
class Order < ActiveRecord::Base
|
97
|
+
has_many :order_transitions
|
98
|
+
|
99
|
+
# Initialize the state machine
|
100
|
+
def state_machine
|
101
|
+
@state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Optionally delegate some methods
|
105
|
+
delegate :can_transition_to?, :transition_to!, :transition_to, :current_state,
|
106
|
+
to: :state_machine
|
107
|
+
end
|
108
|
+
```
|
109
|
+
|
110
|
+
## Configuration
|
111
|
+
|
112
|
+
#### `storage_adapter`
|
113
|
+
|
114
|
+
```ruby
|
115
|
+
Statesman.configure do
|
116
|
+
storage_adapter(Statesman::Adapters::ActiveRecord)
|
117
|
+
end
|
118
|
+
```
|
119
|
+
Statesman defaults to storing transitions in memory. If you're using rails, you can instead configure it to persist transitions to the database by using the ActiveRecord adapter.
|
120
|
+
|
121
|
+
#### `transition_class`
|
122
|
+
```ruby
|
123
|
+
Statesman.configure do
|
124
|
+
transition_class(OrderTransition)
|
125
|
+
end
|
126
|
+
```
|
127
|
+
Configure the transition model. For now that means serializing metadata to JSON.
|
128
|
+
|
129
|
+
|
130
|
+
## Class methods
|
131
|
+
|
132
|
+
#### `Machine.state`
|
133
|
+
```ruby
|
134
|
+
Machine.state(:some_state, initial: true)
|
135
|
+
Machine.state(:another_state)
|
136
|
+
```
|
137
|
+
Define a new state and optionally mark as the initial state.
|
138
|
+
|
139
|
+
#### `Machine.transition`
|
140
|
+
```ruby
|
141
|
+
Machine.transition(from: :some_state, to: :another_state)
|
142
|
+
```
|
143
|
+
Define a transition rule. Both method parameters are required, `to` can also be an array of states (`.transition(from: :some_state, to: [:another_state, :some_other_state])`).
|
144
|
+
|
145
|
+
#### `Machine.guard_transition`
|
146
|
+
```ruby
|
147
|
+
Machine.guard_transition(from: :some_state, to: another_state) do |object|
|
148
|
+
object.some_boolean?
|
149
|
+
end
|
150
|
+
```
|
151
|
+
Define a guard. `to` and `from` parameters are optional, a nil parameter means guard all transitions. The passed block should evaluate to a boolean and must be idempotent as it could be called many times.
|
152
|
+
|
153
|
+
#### `Machine.before_transition`
|
154
|
+
```ruby
|
155
|
+
Machine.before_transition(from: :some_state, to: another_state) do |object|
|
156
|
+
object.side_effect
|
157
|
+
end
|
158
|
+
```
|
159
|
+
Define a callback to run before a transition. `to` and `from` parameters are optional, a nil parameter means run before all transitions. This callback can have side-effects as it will only be run once immediately before the transition.
|
160
|
+
|
161
|
+
#### `Machine.after_transition`
|
162
|
+
```ruby
|
163
|
+
Machine.after_transition(from: :some_state, to: another_state) do |object, transition|
|
164
|
+
object.side_effect
|
165
|
+
end
|
166
|
+
```
|
167
|
+
Define a callback to run after a successful transition. `to` and `from` parameters are optional, a nil parameter means run after all transitions. The model object and transition object are passed as arguments to the callback. This callback can have side-effects as it will only be run once immediately after the transition.
|
168
|
+
|
169
|
+
#### `Machine.new`
|
170
|
+
```ruby
|
171
|
+
my_machine = Machine.new(my_model, transition_class: MyTransitionModel)
|
172
|
+
```
|
173
|
+
Initialize a new state machine instance. `my_model` is required. If using the ActiveRecord adapter `my_model` should have a `has_many` association with `MyTransitionModel`.
|
174
|
+
|
175
|
+
## Instance methods
|
176
|
+
|
177
|
+
#### `Machine#current_state`
|
178
|
+
Returns the current state based on existing transition objects.
|
179
|
+
|
180
|
+
#### `Machine#history`
|
181
|
+
Returns a sorted array of all transition objects.
|
182
|
+
|
183
|
+
#### `Machine#last_transition`
|
184
|
+
Returns the most recent transition object.
|
185
|
+
|
186
|
+
#### `Machine#can_transition_to?(:state)`
|
187
|
+
Returns true if the current state can transition to the passed state and all applicable guards pass.
|
188
|
+
|
189
|
+
#### `Machine#transition_to!(:state)`
|
190
|
+
Transition to the passed state, returning `true` on success. Raises `Statesman::GuardFailedError` or `Statesman::TransitionFailedError` on failure.
|
191
|
+
|
192
|
+
#### `Machine#transition_to(:state)`
|
193
|
+
Transition to the passed state, returning `true` on success. Swallows all exceptions and returns false on failure.
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/circle.yml
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
require "rails/generators"
|
2
|
+
|
3
|
+
# Add statesman attributes to a pre-existing transition class
|
4
|
+
module Statesman
|
5
|
+
class MigrationGenerator < Rails::Generators::Base
|
6
|
+
desc "Add the required Statesman attributes to your transition model"
|
7
|
+
|
8
|
+
argument :parent, type: :string, desc: "Your parent model name"
|
9
|
+
argument :klass, type: :string, desc: "Your transition model name"
|
10
|
+
|
11
|
+
source_root File.expand_path('../templates', __FILE__)
|
12
|
+
|
13
|
+
def create_model_file
|
14
|
+
template("update_migration.rb.erb", file_name)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def next_migration_number
|
20
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
21
|
+
end
|
22
|
+
|
23
|
+
def file_name
|
24
|
+
"db/migrate/#{next_migration_number}_add_statesman_to_#{table_name}.rb"
|
25
|
+
end
|
26
|
+
|
27
|
+
def table_name
|
28
|
+
klass.underscore.pluralize
|
29
|
+
end
|
30
|
+
|
31
|
+
def parent_id
|
32
|
+
parent.underscore + "_id"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class Create<%= klass.pluralize %> < ActiveRecord::Migration
|
2
|
+
def change
|
3
|
+
create_table :<%= table_name %> do |t|
|
4
|
+
t.string :to_state
|
5
|
+
t.text :metadata, default: "{}"
|
6
|
+
t.integer :sort_key
|
7
|
+
t.integer :<%= parent_id %>
|
8
|
+
end
|
9
|
+
|
10
|
+
add_index :<%= table_name %>, :<%= parent_id %>
|
11
|
+
add_index :<%= table_name %>, [:sort_key, :<%= parent_id %>], unique: true
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
class AddStatesmanTo<%= klass.pluralize %> < ActiveRecord::Migration
|
2
|
+
def change
|
3
|
+
add_column :<%= table_name %>, :to_state, :string
|
4
|
+
add_column :<%= table_name %>, :metadata, :text, default: "{}"
|
5
|
+
add_column :<%= table_name %>, :sort_key, :integer
|
6
|
+
add_column :<%= table_name %>, :<%= parent_id %>, :integer
|
7
|
+
|
8
|
+
add_index :<%= table_name %>, :<%= parent_id %>
|
9
|
+
add_index :<%= table_name %>, [:sort_key, :<%= parent_id %>], unique: true
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
require "rails/generators"
|
2
|
+
|
3
|
+
module Statesman
|
4
|
+
class TransitionGenerator < Rails::Generators::Base
|
5
|
+
desc "Create a transition model with the required attributes"
|
6
|
+
|
7
|
+
argument :parent, type: :string, desc: "Your parent model name"
|
8
|
+
argument :klass, type: :string, desc: "Your transition model name"
|
9
|
+
|
10
|
+
source_root File.expand_path('../templates', __FILE__)
|
11
|
+
|
12
|
+
def create_model_file
|
13
|
+
template("create_migration.rb.erb", migration_file_name)
|
14
|
+
template("transition_model.rb.erb", model_file_name)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def next_migration_number
|
20
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
21
|
+
end
|
22
|
+
|
23
|
+
def migration_file_name
|
24
|
+
"db/migrate/#{next_migration_number}_create_#{table_name}.rb"
|
25
|
+
end
|
26
|
+
|
27
|
+
def model_file_name
|
28
|
+
"app/models/#{klass.underscore}.rb"
|
29
|
+
end
|
30
|
+
|
31
|
+
def table_name
|
32
|
+
klass.underscore.pluralize
|
33
|
+
end
|
34
|
+
|
35
|
+
def parent_id
|
36
|
+
parent.underscore + "_id"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
data/lib/statesman.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
module Statesman
|
2
|
+
autoload :Config, 'statesman/config'
|
3
|
+
autoload :Machine, 'statesman/machine'
|
4
|
+
autoload :Callback, 'statesman/callback'
|
5
|
+
autoload :Guard, 'statesman/guard'
|
6
|
+
autoload :Transition, 'statesman/transition'
|
7
|
+
autoload :Version, 'statesman/version'
|
8
|
+
require "statesman/adapters/memory"
|
9
|
+
require "statesman/adapters/active_record"
|
10
|
+
|
11
|
+
# Example:
|
12
|
+
# Statesman.configure do
|
13
|
+
# storage_adapter Statesman::ActiveRecordAdapter
|
14
|
+
# end
|
15
|
+
#
|
16
|
+
def self.configure(&block)
|
17
|
+
config = Config.new(block)
|
18
|
+
@storage_adapter = config.adapter_class
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.storage_adapter
|
22
|
+
@storage_adapter || Adapters::Memory
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require "active_record"
|
2
|
+
require "statesman/exceptions"
|
3
|
+
|
4
|
+
module Statesman
|
5
|
+
module Adapters
|
6
|
+
class ActiveRecord
|
7
|
+
attr_reader :transition_class
|
8
|
+
attr_reader :parent_model
|
9
|
+
|
10
|
+
def initialize(transition_class, parent_model)
|
11
|
+
unless transition_class.serialized_attributes.include?("metadata")
|
12
|
+
raise UnserializedMetadataError,
|
13
|
+
"#{transition_class.name}#metadata is not serialized"
|
14
|
+
end
|
15
|
+
@transition_class = transition_class
|
16
|
+
@parent_model = parent_model
|
17
|
+
end
|
18
|
+
|
19
|
+
def create(to, before_cbs, after_cbs, metadata = {})
|
20
|
+
transition = transitions_for_parent.build(to_state: to,
|
21
|
+
sort_key: next_sort_key,
|
22
|
+
metadata: metadata)
|
23
|
+
|
24
|
+
::ActiveRecord::Base.transaction do
|
25
|
+
before_cbs.each { |cb| cb.call(@parent_model, transition) }
|
26
|
+
transition.save!
|
27
|
+
after_cbs.each { |cb| cb.call(@parent_model, transition) }
|
28
|
+
@last_transition = nil
|
29
|
+
end
|
30
|
+
|
31
|
+
transition
|
32
|
+
end
|
33
|
+
|
34
|
+
def history
|
35
|
+
transitions_for_parent.order(:sort_key)
|
36
|
+
end
|
37
|
+
|
38
|
+
def last
|
39
|
+
@last_transition ||= transitions_for_parent.order(:sort_key).last
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def transitions_for_parent
|
45
|
+
@parent_model.send(@transition_class.table_name)
|
46
|
+
end
|
47
|
+
|
48
|
+
def next_sort_key
|
49
|
+
(last && last.sort_key + 10) || 0
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|