verborghs-state_machine 0.9.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (89) hide show
  1. data/CHANGELOG.rdoc +360 -0
  2. data/LICENSE +20 -0
  3. data/README.rdoc +635 -0
  4. data/Rakefile +77 -0
  5. data/examples/AutoShop_state.png +0 -0
  6. data/examples/Car_state.png +0 -0
  7. data/examples/TrafficLight_state.png +0 -0
  8. data/examples/Vehicle_state.png +0 -0
  9. data/examples/auto_shop.rb +11 -0
  10. data/examples/car.rb +19 -0
  11. data/examples/merb-rest/controller.rb +51 -0
  12. data/examples/merb-rest/model.rb +28 -0
  13. data/examples/merb-rest/view_edit.html.erb +24 -0
  14. data/examples/merb-rest/view_index.html.erb +23 -0
  15. data/examples/merb-rest/view_new.html.erb +13 -0
  16. data/examples/merb-rest/view_show.html.erb +17 -0
  17. data/examples/rails-rest/controller.rb +43 -0
  18. data/examples/rails-rest/migration.rb +11 -0
  19. data/examples/rails-rest/model.rb +23 -0
  20. data/examples/rails-rest/view_edit.html.erb +25 -0
  21. data/examples/rails-rest/view_index.html.erb +23 -0
  22. data/examples/rails-rest/view_new.html.erb +14 -0
  23. data/examples/rails-rest/view_show.html.erb +17 -0
  24. data/examples/traffic_light.rb +7 -0
  25. data/examples/vehicle.rb +31 -0
  26. data/init.rb +1 -0
  27. data/lib/state_machine/assertions.rb +36 -0
  28. data/lib/state_machine/callback.rb +241 -0
  29. data/lib/state_machine/condition_proxy.rb +106 -0
  30. data/lib/state_machine/eval_helpers.rb +83 -0
  31. data/lib/state_machine/event.rb +267 -0
  32. data/lib/state_machine/event_collection.rb +122 -0
  33. data/lib/state_machine/extensions.rb +149 -0
  34. data/lib/state_machine/guard.rb +230 -0
  35. data/lib/state_machine/initializers/merb.rb +1 -0
  36. data/lib/state_machine/initializers/rails.rb +5 -0
  37. data/lib/state_machine/initializers.rb +4 -0
  38. data/lib/state_machine/integrations/active_model/locale.rb +11 -0
  39. data/lib/state_machine/integrations/active_model/observer.rb +45 -0
  40. data/lib/state_machine/integrations/active_model.rb +445 -0
  41. data/lib/state_machine/integrations/active_record/locale.rb +20 -0
  42. data/lib/state_machine/integrations/active_record.rb +522 -0
  43. data/lib/state_machine/integrations/data_mapper/observer.rb +175 -0
  44. data/lib/state_machine/integrations/data_mapper.rb +379 -0
  45. data/lib/state_machine/integrations/mongo_mapper.rb +309 -0
  46. data/lib/state_machine/integrations/sequel.rb +356 -0
  47. data/lib/state_machine/integrations.rb +83 -0
  48. data/lib/state_machine/machine.rb +1645 -0
  49. data/lib/state_machine/machine_collection.rb +64 -0
  50. data/lib/state_machine/matcher.rb +123 -0
  51. data/lib/state_machine/matcher_helpers.rb +54 -0
  52. data/lib/state_machine/node_collection.rb +152 -0
  53. data/lib/state_machine/state.rb +260 -0
  54. data/lib/state_machine/state_collection.rb +112 -0
  55. data/lib/state_machine/transition.rb +399 -0
  56. data/lib/state_machine/transition_collection.rb +244 -0
  57. data/lib/state_machine.rb +421 -0
  58. data/lib/tasks/state_machine.rake +1 -0
  59. data/lib/tasks/state_machine.rb +27 -0
  60. data/test/files/en.yml +9 -0
  61. data/test/files/switch.rb +11 -0
  62. data/test/functional/state_machine_test.rb +980 -0
  63. data/test/test_helper.rb +4 -0
  64. data/test/unit/assertions_test.rb +40 -0
  65. data/test/unit/callback_test.rb +728 -0
  66. data/test/unit/condition_proxy_test.rb +328 -0
  67. data/test/unit/eval_helpers_test.rb +222 -0
  68. data/test/unit/event_collection_test.rb +324 -0
  69. data/test/unit/event_test.rb +795 -0
  70. data/test/unit/guard_test.rb +909 -0
  71. data/test/unit/integrations/active_model_test.rb +956 -0
  72. data/test/unit/integrations/active_record_test.rb +1918 -0
  73. data/test/unit/integrations/data_mapper_test.rb +1814 -0
  74. data/test/unit/integrations/mongo_mapper_test.rb +1382 -0
  75. data/test/unit/integrations/sequel_test.rb +1492 -0
  76. data/test/unit/integrations_test.rb +50 -0
  77. data/test/unit/invalid_event_test.rb +7 -0
  78. data/test/unit/invalid_transition_test.rb +7 -0
  79. data/test/unit/machine_collection_test.rb +565 -0
  80. data/test/unit/machine_test.rb +2349 -0
  81. data/test/unit/matcher_helpers_test.rb +37 -0
  82. data/test/unit/matcher_test.rb +155 -0
  83. data/test/unit/node_collection_test.rb +207 -0
  84. data/test/unit/state_collection_test.rb +280 -0
  85. data/test/unit/state_machine_test.rb +31 -0
  86. data/test/unit/state_test.rb +848 -0
  87. data/test/unit/transition_collection_test.rb +2098 -0
  88. data/test/unit/transition_test.rb +1384 -0
  89. metadata +176 -0
@@ -0,0 +1,356 @@
1
+ module StateMachine
2
+ module Integrations #:nodoc:
3
+ # Adds support for integrating state machines with Sequel models.
4
+ #
5
+ # == Examples
6
+ #
7
+ # Below is an example of a simple state machine defined within a
8
+ # Sequel model:
9
+ #
10
+ # class Vehicle < Sequel::Model
11
+ # state_machine :initial => :parked do
12
+ # event :ignite do
13
+ # transition :parked => :idling
14
+ # end
15
+ # end
16
+ # end
17
+ #
18
+ # The examples in the sections below will use the above class as a
19
+ # reference.
20
+ #
21
+ # == Actions
22
+ #
23
+ # By default, the action that will be invoked when a state is transitioned
24
+ # is the +save+ action. This will cause the resource to save the changes
25
+ # made to the state machine's attribute. *Note* that if any other changes
26
+ # were made to the resource prior to transition, then those changes will
27
+ # be made as well.
28
+ #
29
+ # For example,
30
+ #
31
+ # vehicle = Vehicle.create # => #<Vehicle @values={:state=>"parked", :name=>nil, :id=>1}>
32
+ # vehicle.name = 'Ford Explorer'
33
+ # vehicle.ignite # => true
34
+ # vehicle.refresh # => #<Vehicle @values={:state=>"idling", :name=>"Ford Explorer", :id=>1}>
35
+ #
36
+ # == Events
37
+ #
38
+ # As described in StateMachine::InstanceMethods#state_machine, event
39
+ # attributes are created for every machine that allow transitions to be
40
+ # performed automatically when the object's action (in this case, :save)
41
+ # is called.
42
+ #
43
+ # In Sequel, these automated events are run in the following order:
44
+ # * before validation - Run before callbacks and persist new states, then validate
45
+ # * before save - If validation was skipped, run before callbacks and persist new states, then save
46
+ # * after save - Run after callbacks
47
+ #
48
+ # For example,
49
+ #
50
+ # vehicle = Vehicle.create # => #<Vehicle @values={:state=>"parked", :name=>nil, :id=>1}>
51
+ # vehicle.state_event # => nil
52
+ # vehicle.state_event = 'invalid'
53
+ # vehicle.valid? # => false
54
+ # vehicle.errors.full_messages # => ["state_event is invalid"]
55
+ #
56
+ # vehicle.state_event = 'ignite'
57
+ # vehicle.valid? # => true
58
+ # vehicle.save # => #<Vehicle @values={:state=>"idling", :name=>nil, :id=>1}>
59
+ # vehicle.state # => "idling"
60
+ # vehicle.state_event # => nil
61
+ #
62
+ # Note that this can also be done on a mass-assignment basis:
63
+ #
64
+ # vehicle = Vehicle.create(:state_event => 'ignite') # => #<Vehicle @values={:state=>"idling", :name=>nil, :id=>1}>
65
+ # vehicle.state # => "idling"
66
+ #
67
+ # This technique is always used for transitioning states when the +save+
68
+ # action (which is the default) is configured for the machine.
69
+ #
70
+ # === Security implications
71
+ #
72
+ # Beware that public event attributes mean that events can be fired
73
+ # whenever mass-assignment is being used. If you want to prevent malicious
74
+ # users from tampering with events through URLs / forms, the attribute
75
+ # should be protected like so:
76
+ #
77
+ # class Vehicle < Sequel::Model
78
+ # set_restricted_columns :state_event
79
+ # # set_allowed_columns ... # Alternative technique
80
+ #
81
+ # state_machine do
82
+ # ...
83
+ # end
84
+ # end
85
+ #
86
+ # If you want to only have *some* events be able to fire via mass-assignment,
87
+ # you can build two state machines (one public and one protected) like so:
88
+ #
89
+ # class Vehicle < Sequel::Model
90
+ # set_restricted_columns :state_event # Prevent access to events in the first machine
91
+ #
92
+ # state_machine do
93
+ # # Define private events here
94
+ # end
95
+ #
96
+ # # Allow both machines to share the same state
97
+ # state_machine :public_state, :attribute => :state do
98
+ # # Define public events here
99
+ # end
100
+ # end
101
+ #
102
+ # == Transactions
103
+ #
104
+ # In order to ensure that any changes made during transition callbacks
105
+ # are rolled back during a failed attempt, every transition is wrapped
106
+ # within a transaction.
107
+ #
108
+ # For example,
109
+ #
110
+ # class Message < Sequel::Model
111
+ # end
112
+ #
113
+ # Vehicle.state_machine do
114
+ # before_transition do |transition|
115
+ # Message.create(:content => transition.inspect)
116
+ # false
117
+ # end
118
+ # end
119
+ #
120
+ # vehicle = Vehicle.create # => #<Vehicle @values={:state=>"parked", :name=>nil, :id=>1}>
121
+ # vehicle.ignite # => false
122
+ # Message.count # => 0
123
+ #
124
+ # *Note* that only before callbacks that halt the callback chain and
125
+ # failed attempts to save the record will result in the transaction being
126
+ # rolled back. If an after callback halts the chain, the previous result
127
+ # still applies and the transaction is *not* rolled back.
128
+ #
129
+ # To turn off transactions:
130
+ #
131
+ # class Vehicle < Sequel::Model
132
+ # state_machine :initial => :parked, :use_transactions => false do
133
+ # ...
134
+ # end
135
+ # end
136
+ #
137
+ # If using the +save+ action for the machine, this option will be ignored as
138
+ # the transaction will be created by Sequel within +save+.
139
+ #
140
+ # == Validation errors
141
+ #
142
+ # If an event fails to successfully fire because there are no matching
143
+ # transitions for the current record, a validation error is added to the
144
+ # record's state attribute to help in determining why it failed and for
145
+ # reporting via the UI.
146
+ #
147
+ # For example,
148
+ #
149
+ # vehicle = Vehicle.create(:state => 'idling') # => #<Vehicle @values={:state=>"parked", :name=>nil, :id=>1}>
150
+ # vehicle.ignite # => false
151
+ # vehicle.errors.full_messages # => ["state cannot transition via \"ignite\""]
152
+ #
153
+ # If an event fails to fire because of a validation error on the record and
154
+ # *not* because a matching transition was not available, no error messages
155
+ # will be added to the state attribute.
156
+ #
157
+ # == Scopes
158
+ #
159
+ # To assist in filtering models with specific states, a series of class
160
+ # methods are defined on the model for finding records with or without a
161
+ # particular set of states.
162
+ #
163
+ # These named scopes are the functional equivalent of the following
164
+ # definitions:
165
+ #
166
+ # class Vehicle < Sequel::Model
167
+ # class << self
168
+ # def with_states(*states)
169
+ # filter(:state => states)
170
+ # end
171
+ # alias_method :with_state, :with_states
172
+ #
173
+ # def without_states(*states)
174
+ # filter(~{:state => states})
175
+ # end
176
+ # alias_method :without_state, :without_states
177
+ # end
178
+ # end
179
+ #
180
+ # *Note*, however, that the states are converted to their stored values
181
+ # before being passed into the query.
182
+ #
183
+ # Because of the way scopes work in Sequel, they can be chained like so:
184
+ #
185
+ # Vehicle.with_state(:parked).order(:id.desc)
186
+ #
187
+ # == Callbacks
188
+ #
189
+ # All before/after transition callbacks defined for Sequel resources
190
+ # behave in the same way that other Sequel hooks behave. Rather than
191
+ # passing in the record as an argument to the callback, the callback is
192
+ # instead bound to the object and evaluated within its context.
193
+ #
194
+ # For example,
195
+ #
196
+ # class Vehicle < Sequel::Model
197
+ # state_machine :initial => :parked do
198
+ # before_transition any => :idling do
199
+ # put_on_seatbelt
200
+ # end
201
+ #
202
+ # before_transition do |transition|
203
+ # # log message
204
+ # end
205
+ #
206
+ # event :ignite do
207
+ # transition :parked => :idling
208
+ # end
209
+ # end
210
+ #
211
+ # def put_on_seatbelt
212
+ # ...
213
+ # end
214
+ # end
215
+ #
216
+ # Note, also, that the transition can be accessed by simply defining
217
+ # additional arguments in the callback block.
218
+ module Sequel
219
+ # The default options to use for state machines using this integration
220
+ class << self; attr_reader :defaults; end
221
+ @defaults = {:action => :save}
222
+
223
+ # Should this integration be used for state machines in the given class?
224
+ # Classes that include Sequel::Model will automatically use the Sequel
225
+ # integration.
226
+ def self.matches?(klass)
227
+ defined?(::Sequel::Model) && klass <= ::Sequel::Model
228
+ end
229
+
230
+ # Loads additional files specific to Sequel
231
+ def self.extended(base) #:nodoc:
232
+ require 'sequel/extensions/inflector' if ::Sequel.const_defined?('VERSION') && ::Sequel::VERSION >= '2.12.0'
233
+ end
234
+
235
+ # Forces the change in state to be recognized regardless of whether the
236
+ # state value actually changed
237
+ def write(object, attribute, value)
238
+ result = super
239
+ column = self.attribute.to_sym
240
+ object.changed_columns << column if attribute == :state && owner_class.columns.include?(column) && !object.changed_columns.include?(column)
241
+ result
242
+ end
243
+
244
+ # Adds a validation error to the given object
245
+ def invalidate(object, attribute, message, values = [])
246
+ object.errors.add(self.attribute(attribute), generate_message(message, values))
247
+ end
248
+
249
+ # Resets any errors previously added when invalidating the given object
250
+ def reset(object)
251
+ object.errors.clear
252
+ end
253
+
254
+ protected
255
+ # Defines an initialization hook into the owner class for setting the
256
+ # initial state of the machine *before* any attributes are set on the
257
+ # object
258
+ def define_state_initializer
259
+ @instance_helper_module.class_eval <<-end_eval, __FILE__, __LINE__
260
+ # Hooks in to attribute initialization to set the states *prior*
261
+ # to the attributes being set
262
+ def set(hash, *args)
263
+ if new? && !@initialized_state_machines
264
+ @initialized_state_machines = true
265
+
266
+ ignore = setter_methods(nil, nil).map {|setter| setter.chop.to_sym} & (hash ? hash.keys.map {|attribute| attribute.to_sym} : [])
267
+ initialize_state_machines(:dynamic => false, :ignore => ignore)
268
+ result = super
269
+ initialize_state_machines(:dynamic => true, :ignore => ignore)
270
+ result
271
+ else
272
+ super
273
+ end
274
+ end
275
+ end_eval
276
+ end
277
+
278
+ # Skips defining reader/writer methods since this is done automatically
279
+ def define_state_accessor
280
+ name = self.name
281
+ owner_class.validates_each(attribute) do |record, attr, value|
282
+ machine = record.class.state_machine(name)
283
+ machine.invalidate(record, :state, :invalid) unless machine.states.match(record)
284
+ end
285
+ end
286
+
287
+ # Adds hooks into validation for automatically firing events. This is
288
+ # a bit more complicated than other integrations since Sequel doesn't
289
+ # provide an easy way to hook around validation / save calls
290
+ def define_action_helpers
291
+ if action == :save
292
+ @instance_helper_module.class_eval do
293
+ define_method(:valid?) do |*args|
294
+ yielded = false
295
+ result = self.class.state_machines.transitions(self, :save, :after => false).perform do
296
+ yielded = true
297
+ super(*args)
298
+ end
299
+
300
+ if defined?(::Sequel::MAJOR) && (::Sequel::MAJOR > 3 || ::Sequel::MAJOR == 3 && ::Sequel::MINOR > 13)
301
+ raise_on_failure?(args.first || {}) && !yielded && !result ? raise_hook_failure(:validation) : result
302
+ else
303
+ raise_on_save_failure && !yielded && !result ? save_failure(:validation) : result
304
+ end
305
+ end
306
+
307
+ define_method(defined?(::Sequel::MAJOR) && (::Sequel::MAJOR >= 3 || ::Sequel::MAJOR == 2 && ::Sequel::MINOR == 12) ? :_save : :save) do |*args|
308
+ yielded = false
309
+ result = self.class.state_machines.transitions(self, :save).perform do
310
+ yielded = true
311
+ super(*args)
312
+ end
313
+
314
+ if yielded || result
315
+ result
316
+ elsif defined?(::Sequel::MAJOR) && (::Sequel::MAJOR > 3 || ::Sequel::MAJOR == 3 && ::Sequel::MINOR > 13)
317
+ raise_hook_failure(:save)
318
+ else
319
+ save_failure(:save)
320
+ end
321
+ end
322
+ end unless owner_class.state_machines.any? {|name, machine| machine.action == :save && machine != self}
323
+ else
324
+ super
325
+ end
326
+ end
327
+
328
+ # Creates a scope for finding records *with* a particular state or
329
+ # states for the attribute
330
+ def create_with_scope(name)
331
+ lambda {|model, values| model.filter(:"#{owner_class.table_name}__#{attribute}" => values)}
332
+ end
333
+
334
+ # Creates a scope for finding records *without* a particular state or
335
+ # states for the attribute
336
+ def create_without_scope(name)
337
+ lambda {|model, values| model.filter(~{:"#{owner_class.table_name}__#{attribute}" => values})}
338
+ end
339
+
340
+ # Runs a new database transaction, rolling back any changes if the
341
+ # yielded block fails (i.e. returns false).
342
+ def transaction(object)
343
+ object.db.transaction {raise ::Sequel::Error::Rollback unless yield}
344
+ end
345
+
346
+ # Creates a new callback in the callback chain, always ensuring that
347
+ # it's configured to bind to the object as this is the convention for
348
+ # Sequel callbacks
349
+ def add_callback(type, options, &block)
350
+ options[:bind_to_object] = true
351
+ options[:terminator] = @terminator ||= lambda {|result| result == false}
352
+ super
353
+ end
354
+ end
355
+ end
356
+ end
@@ -0,0 +1,83 @@
1
+ # Load each available integration
2
+ Dir["#{File.dirname(__FILE__)}/integrations/*.rb"].sort.each do |path|
3
+ require "state_machine/integrations/#{File.basename(path)}"
4
+ end
5
+
6
+ module StateMachine
7
+ # Integrations allow state machines to take advantage of features within the
8
+ # context of a particular library. This is currently most useful with
9
+ # database libraries. For example, the various database integrations allow
10
+ # state machines to hook into features like:
11
+ # * Saving
12
+ # * Transactions
13
+ # * Observers
14
+ # * Scopes
15
+ # * Callbacks
16
+ # * Validation errors
17
+ #
18
+ # This type of integration allows the user to work with state machines in a
19
+ # fashion similar to other object models in their application.
20
+ #
21
+ # The integration interface is loosely defined by various unimplemented
22
+ # methods in the StateMachine::Machine class. See that class or the various
23
+ # built-in integrations for more information about how to define additional
24
+ # integrations.
25
+ module Integrations
26
+ # Attempts to find an integration that matches the given class. This will
27
+ # look through all of the built-in integrations under the StateMachine::Integrations
28
+ # namespace and find one that successfully matches the class.
29
+ #
30
+ # == Examples
31
+ #
32
+ # class Vehicle
33
+ # end
34
+ #
35
+ # class ActiveModelVehicle
36
+ # include ActiveModel::Dirty
37
+ # include ActiveModel::Observing
38
+ # include ActiveModel::Validations
39
+ # end
40
+ #
41
+ # class ActiveRecordVehicle < ActiveRecord::Base
42
+ # end
43
+ #
44
+ # class DataMapperVehicle
45
+ # include DataMapper::Resource
46
+ # end
47
+ #
48
+ # class MongoMapperVehicle
49
+ # include MongoMapper::Document
50
+ # end
51
+ #
52
+ # class SequelVehicle < Sequel::Model
53
+ # end
54
+ #
55
+ # StateMachine::Integrations.match(Vehicle) # => nil
56
+ # StateMachine::Integrations.match(ActiveModelVehicle) # => StateMachine::Integrations::ActiveModel
57
+ # StateMachine::Integrations.match(ActiveRecordVehicle) # => StateMachine::Integrations::ActiveRecord
58
+ # StateMachine::Integrations.match(DataMapperVehicle) # => StateMachine::Integrations::DataMapper
59
+ # StateMachine::Integrations.match(MongoMapperVehicle) # => StateMachine::Integrations::MongoMapper
60
+ # StateMachine::Integrations.match(SequelVehicle) # => StateMachine::Integrations::Sequel
61
+ def self.match(klass)
62
+ constants = self.constants.map {|c| c.to_s}.select {|c| c != 'ActiveModel'}.sort << 'ActiveModel'
63
+ if integration = constants.find {|name| const_get(name).matches?(klass)}
64
+ find(integration)
65
+ end
66
+ end
67
+
68
+ # Finds an integration with the given name. If the integration cannot be
69
+ # found, then a NameError exception will be raised.
70
+ #
71
+ # == Examples
72
+ #
73
+ # StateMachine::Integrations.find(:active_record) # => StateMachine::Integrations::ActiveRecord
74
+ # StateMachine::Integrations.find(:active_model) # => StateMachine::Integrations::ActiveModel
75
+ # StateMachine::Integrations.find(:data_mapper) # => StateMachine::Integrations::DataMapper
76
+ # StateMachine::Integrations.find(:mongo_mapper) # => StateMachine::Integrations::MongoMapper
77
+ # StateMachine::Integrations.find(:sequel) # => StateMachine::Integrations::Sequel
78
+ # StateMachine::Integrations.find(:invalid) # => NameError: wrong constant name Invalid
79
+ def self.find(name)
80
+ const_get(name.to_s.gsub(/(?:^|_)(.)/) {$1.upcase})
81
+ end
82
+ end
83
+ end