statesman 0.7.0 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 697717ea3c8a4b388c0b43a8777d9c1a18e147c1
4
- data.tar.gz: 14051a4eacb2979013b8ff8e88698e9be6cff436
3
+ metadata.gz: 22f3c63fa8f737d3fbaef505d2329bd8e0ed1183
4
+ data.tar.gz: ef478f905d16dc128d8ddeaefbf52ccce0731938
5
5
  SHA512:
6
- metadata.gz: 8fef4baf05db94227a2d3d9b2cbce8b7de0b0cc4eebacc6c3c04d879562b823c0903f8044ed36c2d0ba30443600ce7e04b1d109aa0fd4b501a85be51d2e640cc
7
- data.tar.gz: 34deb50e48efc779d09ddff16b61afa90c7a36c263af58f8af8f1995ff8db1ca01a610e00182b9b11eed8d3a665b768c2d4c86dfeecd336e73e47efb90da53f0
6
+ metadata.gz: 810023ee4183eeb404a4ed5cee4d933d45590ab4eaf3a30864fb15181ac8d8de8e9ff0f7ed50a36ecad0e7414d303da750b824cd29d5984d95dfa3095eb93411
7
+ data.tar.gz: 0a83338cf2ab14661eef2fca42ee08511fc9e27538965e5b1213843492efd8bb24aa7bb442ae4b830917a87dba30dd581e434e5d70538054b08560ad6d41aac1
data/.rubocop.yml CHANGED
@@ -1,10 +1,10 @@
1
1
  # For all options see https://github.com/bbatsov/rubocop/tree/master/config
2
2
 
3
3
  AllCops:
4
- Includes:
4
+ Include:
5
5
  - Rakefile
6
6
  - statesman.gemfile
7
- Excludes:
7
+ Exclude:
8
8
  - vendor/**
9
9
 
10
10
  StringLiterals:
@@ -30,3 +30,9 @@ Encoding:
30
30
 
31
31
  LineLength:
32
32
  Max: 80
33
+
34
+ GuardClause:
35
+ Enabled: false
36
+
37
+ SingleSpaceBeforeFirstArg:
38
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## v0.8.0 29 June 2014
2
+ *Additions*
3
+
4
+ - Events. Machines can now define events as a logical grouping of transitions (patch by [@iurimatias](https://github.com/iurimatias))
5
+ - Retries. Individual transitions can be executed with a retry policy by wrapping the method call in a `Machine.retry_conflicts {}` block (patch by [@greysteil](https://github.com/greysteil))
6
+
1
7
  ## v0.7.0 25 June 2014
2
8
  *Additions*
3
9
 
data/Guardfile CHANGED
@@ -1,7 +1,7 @@
1
1
  # A sample Guardfile
2
2
  # More info at https://github.com/guard/guard#readme
3
3
 
4
- guard :rspec, cli: "--color", all_on_start: true do
4
+ guard :rspec, all_on_start: true, cmd: 'bundle exec rspec --color' do
5
5
  watch(%r{^spec/.+_spec\.rb$})
6
6
  watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
7
7
  watch('spec/spec_helper.rb') { "spec" }
data/README.md CHANGED
@@ -96,6 +96,71 @@ Order.first.state_machine.transition_to!(:cancelled)
96
96
  # => true/exception
97
97
  ```
98
98
 
99
+ ## Events
100
+
101
+ ```ruby
102
+ class TaskStateMachine
103
+ include Statesman::Machine
104
+
105
+ state :unstarted, initial: true
106
+ state :started
107
+ state :finished
108
+ state :delivered
109
+ state :accepted
110
+ state :rejected
111
+
112
+ event :start do
113
+ transition from: :unstarted, to: :started
114
+ end
115
+
116
+ event :finish do
117
+ transition from: :started, to: :finished
118
+ end
119
+
120
+ event :deliver do
121
+ transition from: :finished, to: :delivered
122
+ transition from: :started, to: :delivered
123
+ end
124
+
125
+ event :accept do
126
+ transition from: :delivered, to: :accepted
127
+ end
128
+
129
+ event :rejected do
130
+ transition from: :delivered, to: :rejected
131
+ end
132
+
133
+ event :restart do
134
+ transition from: :rejected, to: :started
135
+ end
136
+
137
+ end
138
+
139
+ class Task < ActiveRecord::Base
140
+ delegate :current_state, :trigger!, :available_events, to: :state_machine
141
+
142
+ def state_machine
143
+ @state_machine ||= TaskStateMachine.new(self)
144
+ end
145
+
146
+ end
147
+
148
+ task = Task.new
149
+
150
+ task.current_state
151
+ # => "unstarted"
152
+
153
+ task.trigger!(:start)
154
+ # => true/exception
155
+
156
+ task.current_state
157
+ # => "started"
158
+
159
+ task.available_events
160
+ # => [:finish, :deliver]
161
+
162
+ ```
163
+
99
164
  ## Persistence
100
165
 
101
166
  By default Statesman stores transition history in memory only. It can be
@@ -209,6 +274,15 @@ Initialize a new state machine instance. `my_model` is required. If using the
209
274
  ActiveRecord adapter `my_model` should have a `has_many` association with
210
275
  `MyTransitionModel`.
211
276
 
277
+ #### `Machine.retry_conflicts`
278
+ ```ruby
279
+ Machine.retry_conflicts { instance.transition_to(:new_state) }
280
+ ```
281
+ Automatically retry the given block if a `TransitionConflictError` is raised.
282
+ If you know you want to retry a transition if it fails due to a race condition
283
+ call it from within this block. Takes an (optional) argument for the maximum
284
+ number of retry attempts (defaults to 1).
285
+
212
286
  ## Instance methods
213
287
 
214
288
  #### `Machine#current_state`
@@ -245,7 +319,7 @@ model and define a `transition_class` method.
245
319
  ```ruby
246
320
  class Order < ActiveRecord::Base
247
321
  include Statesman::Adapters::ActiveRecordModel
248
-
322
+
249
323
  private
250
324
 
251
325
  def self.transition_class
data/circle.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  machine:
2
2
  ruby:
3
- version: 2.0.0-p247
3
+ version: 2.0.0-p353
4
4
  test:
5
5
  pre:
6
6
  - bundle exec rubocop
@@ -41,7 +41,7 @@ module Statesman
41
41
  private
42
42
 
43
43
  def transition_class_hash_fields
44
- transition_class.fields.select { |k, v| v.type == Hash }.keys
44
+ transition_class.fields.select { |_, v| v.type == Hash }.keys
45
45
  end
46
46
 
47
47
  def metadata_field_error_message
@@ -0,0 +1,15 @@
1
+ module Statesman
2
+ class EventTransitions
3
+ attr_reader :machine, :event_name
4
+
5
+ def initialize(machine, event_name, &block)
6
+ @machine = machine
7
+ @event_name = event_name
8
+ instance_eval(&block)
9
+ end
10
+
11
+ def transition(options = { from: nil, to: nil })
12
+ machine.transition(options, event_name)
13
+ end
14
+ end
15
+ end
@@ -2,6 +2,7 @@ require_relative "version"
2
2
  require_relative "exceptions"
3
3
  require_relative "guard"
4
4
  require_relative "callback"
5
+ require_relative "event_transitions"
5
6
  require_relative "adapters/memory_transition"
6
7
 
7
8
  module Statesman
@@ -12,6 +13,18 @@ module Statesman
12
13
  base.send(:attr_reader, :object)
13
14
  end
14
15
 
16
+ # Retry any transitions that fail due to a TransitionConflictError
17
+ def self.retry_conflicts(max_retries = 1)
18
+ retry_attempt = 0
19
+
20
+ begin
21
+ yield
22
+ rescue TransitionConflictError
23
+ retry_attempt += 1
24
+ retry_attempt <= max_retries ? retry : raise
25
+ end
26
+ end
27
+
15
28
  module ClassMethods
16
29
  attr_reader :initial_state
17
30
 
@@ -19,6 +32,10 @@ module Statesman
19
32
  @states ||= []
20
33
  end
21
34
 
35
+ def events
36
+ @events ||= {}
37
+ end
38
+
22
39
  def state(name, options = { initial: false })
23
40
  name = name.to_s
24
41
  if options[:initial]
@@ -28,6 +45,10 @@ module Statesman
28
45
  states << name
29
46
  end
30
47
 
48
+ def event(name, &block)
49
+ EventTransitions.new(self, name, &block)
50
+ end
51
+
31
52
  def successors
32
53
  @successors ||= {}
33
54
  end
@@ -41,7 +62,7 @@ module Statesman
41
62
  }
42
63
  end
43
64
 
44
- def transition(options = { from: nil, to: nil })
65
+ def transition(options = { from: nil, to: nil }, event = nil)
45
66
  from = to_s_or_nil(options[:from])
46
67
  to = Array(options[:to]).map { |item| to_s_or_nil(item) }
47
68
 
@@ -50,6 +71,12 @@ module Statesman
50
71
  ([from] + to).each { |state| validate_state(state) }
51
72
 
52
73
  successors[from] += to
74
+
75
+ if event
76
+ events[event] ||= {}
77
+ events[event][from] ||= []
78
+ events[event][from] += to
79
+ end
53
80
  end
54
81
 
55
82
  def before_transition(options = { from: nil, to: nil }, &block)
@@ -189,6 +216,21 @@ module Statesman
189
216
  true
190
217
  end
191
218
 
219
+ def trigger!(event_name, metadata = nil)
220
+ transitions = self.class.events.fetch(event_name) do
221
+ raise Statesman::TransitionFailedError,
222
+ "Event #{event_name} not found"
223
+ end
224
+
225
+ new_state = transitions.fetch(current_state) do
226
+ raise Statesman::TransitionFailedError,
227
+ "State #{current_state} not found for Event #{event_name}"
228
+ end
229
+
230
+ transition_to!(new_state.first, metadata)
231
+ true
232
+ end
233
+
192
234
  def execute(phase, initial_state, new_state, transition)
193
235
  callbacks = callbacks_for(phase, from: initial_state, to: new_state)
194
236
  callbacks.each { |cb| cb.call(@object, transition) }
@@ -200,6 +242,18 @@ module Statesman
200
242
  false
201
243
  end
202
244
 
245
+ def trigger(event_name, metadata = nil)
246
+ self.trigger!(event_name, metadata)
247
+ rescue TransitionFailedError, GuardFailedError
248
+ false
249
+ end
250
+
251
+ def available_events
252
+ self.class.events.select do |_, transitions|
253
+ transitions.key?(current_state)
254
+ end.map(&:first)
255
+ end
256
+
203
257
  private
204
258
 
205
259
  def adapter_class(transition_class)
@@ -1,3 +1,3 @@
1
1
  module Statesman
2
- VERSION = "0.7.0"
2
+ VERSION = "0.8.0"
3
3
  end
@@ -9,20 +9,20 @@ describe Statesman::ActiveRecordTransitionGenerator, type: :generator do
9
9
  end
10
10
 
11
11
  describe 'properly adds class names' do
12
- before { run_generator %w[Yummy::Bacon Yummy::BaconTransition] }
12
+ before { run_generator %w(Yummy::Bacon Yummy::BaconTransition) }
13
13
  subject { file('app/models/yummy/bacon_transition.rb') }
14
14
 
15
- it { should contain(/:bacon_transition/) }
16
- it { should_not contain(/:yummy\/bacon/) }
17
- it { should contain(/class_name: 'Yummy::Bacon'/) }
15
+ it { is_expected.to contain(/:bacon_transition/) }
16
+ it { is_expected.not_to contain(/:yummy\/bacon/) }
17
+ it { is_expected.to contain(/class_name: 'Yummy::Bacon'/) }
18
18
  end
19
19
 
20
20
  describe 'properly formats without class names' do
21
- before { run_generator %w[Bacon BaconTransition] }
21
+ before { run_generator %w(Bacon BaconTransition) }
22
22
  subject { file('app/models/bacon_transition.rb') }
23
23
 
24
- it { should_not contain(/class_name:/) }
25
- it { should contain(/class BaconTransition/) }
24
+ it { is_expected.not_to contain(/class_name:/) }
25
+ it { is_expected.to contain(/class BaconTransition/) }
26
26
  end
27
27
 
28
28
  end
@@ -22,11 +22,11 @@ describe Statesman::MigrationGenerator, type: :generator do
22
22
  end
23
23
 
24
24
  before do
25
- Time.stub(:now).and_return(mock_time)
26
- run_generator %w[Yummy::Bacon Yummy::BaconTransition]
25
+ allow(Time).to receive(:now).and_return(mock_time)
26
+ run_generator %w(Yummy::Bacon Yummy::BaconTransition)
27
27
  end
28
28
 
29
- it { should contain(/:bacon_transition/) }
30
- it { should_not contain(/:yummy\/bacon/) }
29
+ it { is_expected.to contain(/:bacon_transition/) }
30
+ it { is_expected.not_to contain(/:yummy\/bacon/) }
31
31
  end
32
32
  end
@@ -5,19 +5,19 @@ require "generators/statesman/mongoid_transition_generator"
5
5
  describe Statesman::MongoidTransitionGenerator, type: :generator do
6
6
 
7
7
  describe 'the model contains the correct words' do
8
- before { run_generator %w[Yummy::Bacon Yummy::BaconTransition] }
8
+ before { run_generator %w(Yummy::Bacon Yummy::BaconTransition) }
9
9
  subject { file('app/models/yummy/bacon_transition.rb') }
10
10
 
11
- it { should_not contain(/:yummy\/bacon/) }
12
- it { should contain(/class_name: 'Yummy::Bacon'/) }
11
+ it { is_expected.not_to contain(/:yummy\/bacon/) }
12
+ it { is_expected.to contain(/class_name: 'Yummy::Bacon'/) }
13
13
  end
14
14
 
15
15
  describe 'the model contains the correct words' do
16
- before { run_generator %w[Bacon BaconTransition] }
16
+ before { run_generator %w(Bacon BaconTransition) }
17
17
  subject { file('app/models/bacon_transition.rb') }
18
18
 
19
- it { should_not contain(/class_name:/) }
20
- it { should_not contain(/CreateYummy::Bacon/) }
19
+ it { is_expected.not_to contain(/class_name:/) }
20
+ it { is_expected.not_to contain(/CreateYummy::Bacon/) }
21
21
  end
22
22
 
23
23
  end
data/spec/spec_helper.rb CHANGED
@@ -3,11 +3,11 @@ require "sqlite3"
3
3
  require "active_record"
4
4
  require "support/active_record"
5
5
  require "mongoid"
6
+ require 'rspec/its'
6
7
 
7
8
  RSpec.configure do |config|
8
- config.expect_with :rspec do |c|
9
- c.syntax = :expect
10
- end
9
+ config.raise_errors_for_deprecations!
10
+ config.mock_with(:rspec) { |mocks| mocks.verify_partial_doubles = true }
11
11
 
12
12
  config.order = "random"
13
13
 
@@ -31,27 +31,27 @@ describe Statesman::Adapters::ActiveRecordModel do
31
31
  context "given a single state" do
32
32
  subject { MyActiveRecordModel.in_state(:state_a) }
33
33
 
34
- it { should include model }
34
+ it { is_expected.to include model }
35
35
  end
36
36
 
37
37
  context "given multiple states" do
38
38
  subject { MyActiveRecordModel.in_state(:state_a, :state_b) }
39
39
 
40
- it { should include model }
41
- it { should include other_model }
40
+ it { is_expected.to include model }
41
+ it { is_expected.to include other_model }
42
42
  end
43
43
  end
44
44
 
45
45
  describe ".not_in_state" do
46
46
  context "given a single state" do
47
47
  subject { MyActiveRecordModel.not_in_state(:state_b) }
48
- it { should include model }
49
- it { should_not include other_model }
48
+ it { is_expected.to include model }
49
+ it { is_expected.not_to include other_model }
50
50
  end
51
51
 
52
52
  context "given multiple states" do
53
53
  subject { MyActiveRecordModel.not_in_state(:state_a, :state_b) }
54
- it { should == [] }
54
+ it { is_expected.to eq([]) }
55
55
  end
56
56
  end
57
57
  end
@@ -11,7 +11,7 @@ describe Statesman::Adapters::ActiveRecord do
11
11
  before { MyActiveRecordModelTransition.serialize(:metadata, JSON) }
12
12
  let(:observer) do
13
13
  result = double(Statesman::Machine)
14
- result.stub(:execute)
14
+ allow(result).to receive(:execute)
15
15
  result
16
16
  end
17
17
  let(:model) { MyActiveRecordModel.create(current_state: :pending) }
@@ -21,10 +21,11 @@ describe Statesman::Adapters::ActiveRecord do
21
21
  context "with unserialized metadata and non json column type" do
22
22
  before do
23
23
  metadata_column = double
24
- metadata_column.stub(sql_type: '')
25
- MyActiveRecordModelTransition.stub(columns_hash:
24
+ allow(metadata_column).to receive_messages(sql_type: '')
25
+ allow(MyActiveRecordModelTransition).to receive_messages(columns_hash:
26
26
  { 'metadata' => metadata_column })
27
- MyActiveRecordModelTransition.stub(serialized_attributes: {})
27
+ allow(MyActiveRecordModelTransition)
28
+ .to receive_messages(serialized_attributes: {})
28
29
  end
29
30
 
30
31
  it "raises an exception" do
@@ -38,11 +39,11 @@ describe Statesman::Adapters::ActiveRecord do
38
39
  context "with serialized metadata and json column type" do
39
40
  before do
40
41
  metadata_column = double
41
- metadata_column.stub(sql_type: 'json')
42
- MyActiveRecordModelTransition.stub(columns_hash:
42
+ allow(metadata_column).to receive_messages(sql_type: 'json')
43
+ allow(MyActiveRecordModelTransition).to receive_messages(columns_hash:
43
44
  { 'metadata' => metadata_column })
44
- MyActiveRecordModelTransition.stub(serialized_attributes:
45
- { 'metadata' => '' })
45
+ allow(MyActiveRecordModelTransition)
46
+ .to receive_messages(serialized_attributes: { 'metadata' => '' })
46
47
  end
47
48
 
48
49
  it "raises an exception" do
@@ -82,12 +83,12 @@ describe Statesman::Adapters::ActiveRecord do
82
83
 
83
84
  context "ActiveRecord::RecordNotUnique unrelated to this transition" do
84
85
  let(:error) { ActiveRecord::RecordNotUnique.new("unrelated", nil) }
85
- it { should raise_exception(ActiveRecord::RecordNotUnique) }
86
+ it { is_expected.to raise_exception(ActiveRecord::RecordNotUnique) }
86
87
  end
87
88
 
88
89
  context "other errors" do
89
90
  let(:error) { StandardError }
90
- it { should raise_exception(StandardError) }
91
+ it { is_expected.to raise_exception(StandardError) }
91
92
  end
92
93
  end
93
94
  end
@@ -107,8 +108,8 @@ describe Statesman::Adapters::ActiveRecord do
107
108
  end
108
109
 
109
110
  it "caches the transition" do
110
- MyActiveRecordModel.any_instance
111
- .should_receive(:my_active_record_model_transitions).never
111
+ expect_any_instance_of(MyActiveRecordModel)
112
+ .to receive(:my_active_record_model_transitions).never
112
113
  adapter.last
113
114
  end
114
115
 
@@ -127,7 +128,7 @@ describe Statesman::Adapters::ActiveRecord do
127
128
  end
128
129
 
129
130
  it "doesn't query the database" do
130
- MyActiveRecordModelTransition.should_not_receive(:connection)
131
+ expect(MyActiveRecordModelTransition).not_to receive(:connection)
131
132
  expect(adapter.last.to_state).to eq("y")
132
133
  end
133
134
  end