statesman 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
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
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in statesman.gemspec
4
+ gemspec
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
+ ![Statesman](http://f.cl.ly/items/410n2A0S3l1W0i3i0o2K/statesman.png)
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,9 @@
1
+ machine:
2
+ ruby:
3
+ version: 2.0.0-p247
4
+ test:
5
+ pre:
6
+ - bundle exec rubocop
7
+ database:
8
+ override:
9
+ - echo "no database step needed"
@@ -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,4 @@
1
+ class <%= klass %> < ActiveRecord::Base
2
+ attr_accessible :to_state, :metadata, :sort_key
3
+ belongs_to :<%= parent.underscore %>, inverse_of: :<%= table_name %>
4
+ 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