composable_state_machine 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +17 -0
  3. data/.rspec +3 -0
  4. data/.simplecov +4 -0
  5. data/.travis.yml +8 -0
  6. data/.yardopts +4 -0
  7. data/Gemfile +4 -0
  8. data/Guardfile +5 -0
  9. data/LICENSE.txt +22 -0
  10. data/README.md +352 -0
  11. data/Rakefile +19 -0
  12. data/assets/class-diagram.yuml +24 -0
  13. data/assets/uml-class-diagram.png +0 -0
  14. data/composable_state_machine.gemspec +35 -0
  15. data/lib/composable_state_machine.rb +45 -0
  16. data/lib/composable_state_machine/behaviors.rb +48 -0
  17. data/lib/composable_state_machine/callback_runner.rb +19 -0
  18. data/lib/composable_state_machine/callbacks.rb +56 -0
  19. data/lib/composable_state_machine/default_callback_runner.rb +16 -0
  20. data/lib/composable_state_machine/invalid_event.rb +7 -0
  21. data/lib/composable_state_machine/invalid_transition.rb +7 -0
  22. data/lib/composable_state_machine/invalid_trigger.rb +7 -0
  23. data/lib/composable_state_machine/machine.rb +21 -0
  24. data/lib/composable_state_machine/machine_with_external_state.rb +41 -0
  25. data/lib/composable_state_machine/model.rb +55 -0
  26. data/lib/composable_state_machine/transitions.rb +73 -0
  27. data/lib/composable_state_machine/version.rb +3 -0
  28. data/spec/integration/auto_update_state_spec.rb +38 -0
  29. data/spec/integration/instance_callbacks_spec.rb +47 -0
  30. data/spec/integration/leave_callbacks_spec.rb +60 -0
  31. data/spec/integration/leave_callbacks_with_composition_spec.rb +68 -0
  32. data/spec/lib/composable_state_machine/behaviors_spec.rb +83 -0
  33. data/spec/lib/composable_state_machine/callback_runner_spec.rb +54 -0
  34. data/spec/lib/composable_state_machine/callbacks_spec.rb +106 -0
  35. data/spec/lib/composable_state_machine/machine_spec.rb +25 -0
  36. data/spec/lib/composable_state_machine/machine_with_external_state_spec.rb +97 -0
  37. data/spec/lib/composable_state_machine/model_spec.rb +76 -0
  38. data/spec/lib/composable_state_machine/transitions_spec.rb +77 -0
  39. data/spec/lib/composable_state_machine_spec.rb +53 -0
  40. data/spec/spec_helper.rb +14 -0
  41. data/spec/support/delegation.rb +196 -0
  42. metadata +218 -0
checksums.yaml ADDED
@@ -0,0 +1,15 @@
1
+ ---
2
+ !binary "U0hBMQ==":
3
+ metadata.gz: !binary |-
4
+ NTgxOGNlZTI4NWQ2YzkwYTY1OGI2YzBlMzQyODI2MTU1Mzg4NTQ0Ng==
5
+ data.tar.gz: !binary |-
6
+ YjdmMDc3NTE4ZDdkNWRjMDk1NWExNTBjMjM1Nzc3NzQyNjBmYTA2Ng==
7
+ SHA512:
8
+ metadata.gz: !binary |-
9
+ NWZlZWE3MTExZjI3YWMxMzUzYjUyMDcyOWEwN2M4NTM2ZGZlOWM1OGYzYjYz
10
+ NTYwY2ZlMGMyNjI5YjIyYTdjNjZlZWYxM2EwYTMxOGRhMzllZGQ5MGEyOWFl
11
+ NjM4YjU2YWU3Yzg2YzZhMmM4YzQ1NzBkMzE0ODIyNDcxZDExOTY=
12
+ data.tar.gz: !binary |-
13
+ NWY5YzRmZmZkODUyY2Q1NTA2Y2Q0MmNlMDQ2MDYzN2JhNDFlODlkNWU3Nzhk
14
+ NThkNGU3MjFhMWM5NjFlNjdlNWI3M2Q3YTY4ODlhMDZlZWM5MWUwNDAyZmVj
15
+ NDJiMDQxYTRiMjkwOTkwMDBlN2MxYzg3NGQyMjNiZThkMzM2ZDg=
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/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --no-drb
2
+ --format progress
3
+ --color
data/.simplecov ADDED
@@ -0,0 +1,4 @@
1
+ SimpleCov.start do
2
+ add_filter "/spec/"
3
+ add_filter "/tmp/"
4
+ end
data/.travis.yml ADDED
@@ -0,0 +1,8 @@
1
+ rvm:
2
+ - 1.9.2
3
+ - 1.9.3
4
+ - 2.0.0
5
+ before_install:
6
+ - gem update --system
7
+ install: "bundle install"
8
+ script: "bundle exec rake"
data/.yardopts ADDED
@@ -0,0 +1,4 @@
1
+ --asset assets:assets
2
+ lib/**/*.rb
3
+ -
4
+ LICENSE.txt
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in composable_state_machine.gemspec
4
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,5 @@
1
+ guard :rspec do
2
+ watch('spec/spec_helper.rb') { "spec" }
3
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
4
+ watch(%r{^spec/([^/]+)/(.+)\.rb$}) { |m| "spec/#{m[1]}/#{m[2]}.rb" }
5
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Simeon Simeonov & Swoop, Inc.
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,352 @@
1
+ # composable\_state_machine
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/composable_state_machine.png)](http://badge.fury.io/rb/composable_state_machine)
4
+ [![Build Status](https://secure.travis-ci.org/swoop-inc/composable_state_machine.png)](http://travis-ci.org/swoop-inc/composable_state_machine?branch=master)
5
+ [![Dependency Status](https://gemnasium.com/swoop-inc/composable_state_machine.png)](https://gemnasium.com/swoop-inc/composable_state_machine)
6
+ [![Code Climate](https://codeclimate.com/repos/526dee6b13d63752bd00675f/badges/22a2593672eb1f0f07e0/gpa.png)](https://codeclimate.com/repos/526dee6b13d63752bd00675f/feed)
7
+ [![Coverage Status](https://coveralls.io/repos/swoop-inc/composable_state_machine/badge.png)](https://coveralls.io/r/swoop-inc/composable_state_machine)
8
+
9
+ Would you like lots of DSL sugar with a bloated state machine implementation that can only be used with ActiveRecord? If so, this is not the gem for you. If you are looking for a simple, flexible and easy to use, extend & debug state machine implementation then look no further.
10
+
11
+ Here are a dozen reasons to give composable\_state_machine a try:
12
+
13
+ 1. Have as many state machines per object as you need.
14
+ 1. Use whichever field/attribute/variable you want to store the machine's state.
15
+ 1. State machine transitions are pure data. You can store them in the database or throw them on the wire.
16
+ 1. Use almost any object to denote a state, including `nil` (sometimes great for the initial state).
17
+ 1. Use almost any object to denote an event.
18
+ 1. Pass optional parameters with events for powerful behaviors.
19
+ 1. Mix state transition specifications and behaviors (callbacks) independently to quickly create state machine variations.
20
+ 1. Share an immutable state machine model with any number of machine instances.
21
+ 1. Add entirely new types of behaviors in less than 10 lines of code.
22
+ 1. Use Procs, blocks, bound or unbound methods for callbacks.
23
+ 1. Easily decorate callbacks for debugging, logging, caching or any other reason.
24
+ 1. There are no runtime dependencies so you can use this gem anywhere.
25
+
26
+ composable\_state\_machine is a horrible but descriptive name. The simplicity and flexibility of the state machine comes from the composition-based implementation, which pays careful attention to [SOLID](http://en.wikipedia.org/wiki/SOLID_(object-oriented_design)). Most classes are under 20 LOC and there is 100% test coverage. If you don't like something, chances are that you can change the behavior by writing a few lines in your own namespace, without needing to fork.
27
+
28
+
29
+ ## Installation
30
+
31
+ Add this line to your application's Gemfile:
32
+
33
+ ```ruby
34
+ gem 'composable_state_machine'
35
+ ```
36
+
37
+ And then execute:
38
+
39
+ ```bash
40
+ $ bundle
41
+ ```
42
+
43
+ Or install it yourself as:
44
+
45
+ ```bash
46
+ $ gem install composable_state_machine
47
+ ```
48
+
49
+
50
+ ## Usage & examples
51
+
52
+ There are many examples in the tests, especially in `spec/integration`. Another easy way to explore is to run `rake console`. This will start an IRB session with the gem preloaded. That's how all the examples below were generated.
53
+
54
+ Before we begin, here is the big picture (skip ahead if you want the [actual picture](#design--implementation)):
55
+
56
+ - A state [Machine](https://github.com/swoop-inc/composable_state_machine/blob/master/lib/composable_state_machine/machine.rb) maintains its own state.
57
+ - A machine's behavior is controlled by its [Model](https://github.com/swoop-inc/composable_state_machine/blob/master/lib/composable_state_machine/model.rb), which specifies:
58
+ - The valid [Transitions](https://github.com/swoop-inc/composable_state_machine/blob/master/lib/composable_state_machine/transitions.rb) in the state machine.
59
+ - The [Behaviors](https://github.com/swoop-inc/composable_state_machine/blob/master/lib/composable_state_machine/behaviors.rb) exhibited during operation.
60
+ - The default initial state.
61
+ - For every type of behavior, a model can include [Callbacks](https://github.com/swoop-inc/composable_state_machine/blob/master/lib/composable_state_machine/callbacks.rb).
62
+ - Which callback is executed depends on the state the machine is in.
63
+ - Callbacks are executed in the right context for each machine instance by a [CallbackRunner](https://github.com/swoop-inc/composable_state_machine/blob/master/lib/composable_state_machine/callback_runner.rb).
64
+ - Models have a [DefaultCallbackRunner](https://github.com/swoop-inc/composable_state_machine/blob/master/lib/composable_state_machine/default_callback_runner.rb).
65
+
66
+ That's all you need to know to do some pretty cool things with this gem.
67
+
68
+ ### Simple state machine with no behaviors
69
+
70
+ ```ruby
71
+ hiring_model = ComposableStateMachine.model(transitions: {
72
+ :hire => {:candidate => :hired, :departed => :hired, :fired => :hired},
73
+ :leave => {:hired => :departed},
74
+ :fire => {:hired => :fired}
75
+ })
76
+ bob = ComposableStateMachine.machine(hiring_model, state: :candidate)
77
+ bob.state
78
+ => :candidate
79
+ bob.trigger(:hire)
80
+ => :hired
81
+ bob.state
82
+ => :hired
83
+ bob == :hired
84
+ => true
85
+ bob.trigger(:hire) # no state transition
86
+ => nil
87
+ bob.state
88
+ => :hired
89
+ ```
90
+
91
+ ### External state management
92
+
93
+ This example shows how you can choose where to store the state of a machine. It's also a great example of processing state change notifications. The power here comes from [MachineWithExternalState](https://github.com/swoop-inc/composable_state_machine/blob/master/lib/composable_state_machine/machine_with_external_state.rb) which takes two procs/methods, one for reading the state and the other for updating the state.
94
+
95
+ ```ruby
96
+ class Room
97
+ MACHINE_MODEL = ComposableStateMachine.model(
98
+ transitions: {
99
+ heat: {cold: :warm, warm: :hot},
100
+ cool: {warm: :cold, hot: :warm},
101
+ }
102
+ )
103
+
104
+ attr_reader :temp
105
+
106
+ def initialize(temp)
107
+ @machine = ComposableStateMachine::MachineWithExternalState.new(
108
+ MACHINE_MODEL, method(:temp), method(:temp=), state: temp)
109
+ end
110
+
111
+ def heat(periods = 1)
112
+ periods.times { @machine.trigger(:heat) }
113
+ self
114
+ end
115
+
116
+ def cool(periods = 1)
117
+ periods.times { @machine.trigger(:cool) }
118
+ self
119
+ end
120
+
121
+ private
122
+
123
+ def temp=(new_value)
124
+ puts " -- Temperature changed from #{temp.inspect} to #{new_value.inspect}"
125
+ @temp = new_value
126
+ end
127
+ end
128
+
129
+ Room.new(:cold).heat(5).cool.temp
130
+ -- Temperature changed from nil to :cold
131
+ -- Temperature changed from :cold to :warm
132
+ -- Temperature changed from :warm to :hot
133
+ -- Temperature changed from :hot to :warm
134
+ => :warm
135
+ ```
136
+
137
+ ### Callbacks
138
+
139
+ By default, the only type of callback in the current implementation is on entering a state. This is easy to change as you will see in the next example but has not been a limitation in the use cases we have encountered so far.
140
+
141
+ The `:enter` behavior uses states as the triggers for callbacks. You can specify any number of callbacks for the same state.
142
+
143
+ There is a special state called `:any`. Callbacks registered for the :any trigger will run every single time their behavior is activated. This makes them great for logging, debugging, implementing an observer pattern, etc.
144
+
145
+ Callbacks here are more powerful than in other state machine implementations we have encountered. They receive the original state, the event causing the transition and the state the machine is transitioning to. In addition, events can be triggered with any number of optional arguments, which are passed to callbacks.
146
+
147
+ Callbacks are executed by callback runners. The default callback runner, the unsurprisingly named DefaultCallbackRunner, simply calls a callback's `call` method, which will use the value of `self` from the binding where the callback was defined. If you want to define your state machine models once and then use them across object instances, which is a really good idea, then you need the callbacks to use the object instance as `self`. To do this, mix in CallbackRunner and pass the object instance as the callback runner, as shown in this example.
148
+
149
+ ```ruby
150
+ class Person
151
+ include ComposableStateMachine::CallbackRunner
152
+
153
+ MACHINE_MODEL = ComposableStateMachine.model(
154
+ transitions: {
155
+ hire: {candidate: :hired, departed: :hired, fired: :hired},
156
+ leave: {hired: :departed},
157
+ fire: {hired: :fired},
158
+ },
159
+ behaviors: {
160
+ enter: {
161
+ hired: proc { puts "Welcome, #{@name}!" },
162
+ fired: [
163
+ proc { puts " Gee, #{@name}!" },
164
+ proc { puts " You got a raw deal, #{@name}..." },
165
+ ],
166
+ any: proc { |current_state, event, new_state|
167
+ puts " Going from #{current_state.inspect} to #{new_state.inspect} " \
168
+ " due to a #{event.inspect} event"
169
+ }
170
+ }
171
+ }
172
+ )
173
+
174
+ def initialize(name, state)
175
+ @name = name
176
+ @machine = ComposableStateMachine.machine(
177
+ MACHINE_MODEL, state: state, callback_runner: self)
178
+ end
179
+
180
+ def hire!
181
+ @machine.trigger(:hire)
182
+ self
183
+ end
184
+
185
+ def fire!
186
+ @machine.trigger(:fire)
187
+ self
188
+ end
189
+ end
190
+
191
+ Person.new('Bob', :candidate).hire!.fire!
192
+ Welcome, Bob!
193
+ Going from :candidate to :hired due to a :hire event
194
+ Gee, Bob!
195
+ You got a raw deal, Bob...
196
+ Going from :hired to :fired due to a :fire event
197
+ ```
198
+
199
+ ### Adding new state machine behaviors
200
+
201
+ What if you needed callbacks fired upon leaving a state? Let's see if composable\_state_machine lives up to its claim that adding these types of behaviors can be done in a few lines of code without forking the gem.
202
+
203
+ ```ruby
204
+ class ModelWithLeaveCallbacks < ComposableStateMachine::Model
205
+ def run_callbacks(callback_runner, current_state, event, new_state, arguments)
206
+ run_callbacks_for(callback_runner, :leave, current_state,
207
+ current_state, event, new_state, *arguments)
208
+ super
209
+ end
210
+ end
211
+
212
+ class Person
213
+ include ComposableStateMachine::CallbackRunner
214
+
215
+ MACHINE_MODEL = ComposableStateMachine.model(
216
+ transitions: {
217
+ hire: {candidate: :hired, departed: :hired, fired: :hired},
218
+ leave: {hired: :departed},
219
+ fire: {hired: :fired},
220
+ },
221
+ behaviors: {
222
+ enter: {
223
+ hired: proc { puts " Welcome, #{@name}!" },
224
+ fired: proc { puts " Gee, #{@name}..." },
225
+ },
226
+ leave: {
227
+ fired: proc { puts ' Is this a good idea?' }
228
+ }
229
+ },
230
+ model_factory: ModelWithLeaveCallbacks
231
+ )
232
+
233
+ def initialize(name, state)
234
+ @name = name
235
+ @machine = ComposableStateMachine.machine(
236
+ MACHINE_MODEL, state: state, callback_runner: self)
237
+ end
238
+
239
+ def hire!
240
+ @machine.trigger(:hire)
241
+ self
242
+ end
243
+
244
+ def fire!
245
+ @machine.trigger(:fire)
246
+ self
247
+ end
248
+ end
249
+
250
+ Person.new('Bob', :candidate).hire!.fire!.hire!
251
+ Welcome, Bob!
252
+ Gee, Bob...
253
+ Is this a good idea?
254
+ Welcome, Bob!
255
+ ```
256
+
257
+ It took us 7 lines of code to extend the behavior of the default Model and one line of code to tell the convenience method `ComposableStateMachine.model` to use our model class as the factory to build models from. Pretty neat. Adding callbacks based on events would only take one extra call to `run_callbacks_for`: just two extra lines. Hopefully, you are starting to get a sense of the power that comes through good use of composition patterns.
258
+
259
+ ### Transitions
260
+
261
+ If you want more control over building the transitions for a state machine model you can do it in code.
262
+
263
+ ```ruby
264
+ transitions = ComposableStateMachine::Transitions.new.
265
+ on(:hire, candidate: :hired, departed: :hired).
266
+ on(:hire, fired: :hired).
267
+ on(:leave, hired: :departed).
268
+ on(:fire, hired: :fired)
269
+ transitions.events.sort
270
+ => [:fire, :hire, :leave]
271
+ transitions.states.sort
272
+ => [:candidate, :departed, :fired, :hired]
273
+ ```
274
+
275
+ ### Behaviors & callbacks
276
+
277
+ By the same token, you can have finer-grained control over configuring behaviors and callbacks.
278
+
279
+ ```ruby
280
+ leave_callbacks = ComposableStateMachine::Callbacks.new.
281
+ on(:candidate, proc { puts 'You will love it here!' }).
282
+ on(:hired, proc { puts 'Sorry to see you go.' })
283
+ custom_callback_manager = lambda { |runner, trigger, *args|
284
+ callback = choose_callback(trigger)
285
+ runner.run_state_machine_callback(callback, *args)
286
+ }
287
+ behaviors = ComposableStateMachine::Behaviors.new.
288
+ on(:enter, {hired: proc { puts 'Welcome!' }}).
289
+ on(:leave, leave_callbacks).
290
+ on(:event, custom_callback_manager)
291
+ ```
292
+
293
+ ## Design & implementation
294
+
295
+ The following picture is a decent approximation of what's going on under the covers, courtesy of [yUML](http://yuml.me), which may be helpful in using and extending the gem. Here are a few pointers:
296
+
297
+ - Because we are using Ruby there are no real interfaces. Think duck-typing.
298
+ - Classes in blue relate to the definition of states and the transitions between them through events.
299
+ - Classes in green relate to behaviors and callbacks.
300
+ - Classes in red are about state machine models.
301
+ - Classes in orange are about state machine instances.
302
+
303
+ ![UML Class Diagram](assets/uml-class-diagram.png "UML Class Diagram")
304
+
305
+ The yUML source for the diagram is [here](assets/class-diagram.yuml).
306
+
307
+
308
+ ## Contributing
309
+
310
+ 1. Fork the repo
311
+ 2. Create a topic branch (`git checkout -b my-new-feature`)
312
+ 4. Commit your changes (`git commit -am 'Add some feature'`)
313
+ 5. Push to the branch (`git push origin my-new-feature`)
314
+ 6. Create new Pull Request
315
+
316
+ Please don't change the version and add solid tests.
317
+
318
+
319
+ ## Credits
320
+
321
+ [Michel Martens](https://github.com/soveran) for creating [micromachine](https://github.com/soveran/micromachine). composable\_state_machine came to life because extending micromachine without breaking backward compatibility turned out to be difficult.
322
+
323
+ composable\_state_machine was written by [Simeon Simeonov](https://github.com/ssimeonov) and is maintained & funded by [Swoop, Inc.](http://swoop.com)
324
+
325
+ ![swoop](http://blog.swoop.com/Portals/160747/images/logo1.png)
326
+
327
+
328
+ License
329
+ -------
330
+
331
+ composable\_state_machine is Copyright © 2013 Simeon Simeonov and Swoop, Inc. It is free software, and may be redistributed under the terms specified below.
332
+
333
+ MIT License
334
+
335
+ Permission is hereby granted, free of charge, to any person obtaining
336
+ a copy of this software and associated documentation files (the
337
+ "Software"), to deal in the Software without restriction, including
338
+ without limitation the rights to use, copy, modify, merge, publish,
339
+ distribute, sublicense, and/or sell copies of the Software, and to
340
+ permit persons to whom the Software is furnished to do so, subject to
341
+ the following conditions:
342
+
343
+ The above copyright notice and this permission notice shall be
344
+ included in all copies or substantial portions of the Software.
345
+
346
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
347
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
348
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
349
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
350
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
351
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
352
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.