verborghs-state_machine 0.9.4

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.
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