statesman 0.7.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
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