hsume2-state_machine 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (110) hide show
  1. data/CHANGELOG.rdoc +413 -0
  2. data/LICENSE +20 -0
  3. data/README.rdoc +717 -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.rb +448 -0
  28. data/lib/state_machine/alternate_machine.rb +79 -0
  29. data/lib/state_machine/assertions.rb +36 -0
  30. data/lib/state_machine/branch.rb +224 -0
  31. data/lib/state_machine/callback.rb +236 -0
  32. data/lib/state_machine/condition_proxy.rb +94 -0
  33. data/lib/state_machine/error.rb +13 -0
  34. data/lib/state_machine/eval_helpers.rb +86 -0
  35. data/lib/state_machine/event.rb +304 -0
  36. data/lib/state_machine/event_collection.rb +139 -0
  37. data/lib/state_machine/extensions.rb +149 -0
  38. data/lib/state_machine/initializers.rb +4 -0
  39. data/lib/state_machine/initializers/merb.rb +1 -0
  40. data/lib/state_machine/initializers/rails.rb +25 -0
  41. data/lib/state_machine/integrations.rb +110 -0
  42. data/lib/state_machine/integrations/active_model.rb +502 -0
  43. data/lib/state_machine/integrations/active_model/locale.rb +11 -0
  44. data/lib/state_machine/integrations/active_model/observer.rb +45 -0
  45. data/lib/state_machine/integrations/active_model/versions.rb +31 -0
  46. data/lib/state_machine/integrations/active_record.rb +424 -0
  47. data/lib/state_machine/integrations/active_record/locale.rb +20 -0
  48. data/lib/state_machine/integrations/active_record/versions.rb +143 -0
  49. data/lib/state_machine/integrations/base.rb +91 -0
  50. data/lib/state_machine/integrations/data_mapper.rb +392 -0
  51. data/lib/state_machine/integrations/data_mapper/observer.rb +210 -0
  52. data/lib/state_machine/integrations/data_mapper/versions.rb +62 -0
  53. data/lib/state_machine/integrations/mongo_mapper.rb +272 -0
  54. data/lib/state_machine/integrations/mongo_mapper/locale.rb +4 -0
  55. data/lib/state_machine/integrations/mongo_mapper/versions.rb +110 -0
  56. data/lib/state_machine/integrations/mongoid.rb +357 -0
  57. data/lib/state_machine/integrations/mongoid/locale.rb +4 -0
  58. data/lib/state_machine/integrations/mongoid/versions.rb +18 -0
  59. data/lib/state_machine/integrations/sequel.rb +428 -0
  60. data/lib/state_machine/integrations/sequel/versions.rb +36 -0
  61. data/lib/state_machine/machine.rb +1873 -0
  62. data/lib/state_machine/machine_collection.rb +87 -0
  63. data/lib/state_machine/matcher.rb +123 -0
  64. data/lib/state_machine/matcher_helpers.rb +54 -0
  65. data/lib/state_machine/node_collection.rb +157 -0
  66. data/lib/state_machine/path.rb +120 -0
  67. data/lib/state_machine/path_collection.rb +90 -0
  68. data/lib/state_machine/state.rb +271 -0
  69. data/lib/state_machine/state_collection.rb +112 -0
  70. data/lib/state_machine/transition.rb +458 -0
  71. data/lib/state_machine/transition_collection.rb +244 -0
  72. data/lib/tasks/state_machine.rake +1 -0
  73. data/lib/tasks/state_machine.rb +27 -0
  74. data/test/files/en.yml +17 -0
  75. data/test/files/switch.rb +11 -0
  76. data/test/functional/alternate_state_machine_test.rb +122 -0
  77. data/test/functional/state_machine_test.rb +993 -0
  78. data/test/test_helper.rb +4 -0
  79. data/test/unit/assertions_test.rb +40 -0
  80. data/test/unit/branch_test.rb +890 -0
  81. data/test/unit/callback_test.rb +701 -0
  82. data/test/unit/condition_proxy_test.rb +328 -0
  83. data/test/unit/error_test.rb +43 -0
  84. data/test/unit/eval_helpers_test.rb +222 -0
  85. data/test/unit/event_collection_test.rb +358 -0
  86. data/test/unit/event_test.rb +985 -0
  87. data/test/unit/integrations/active_model_test.rb +1097 -0
  88. data/test/unit/integrations/active_record_test.rb +2021 -0
  89. data/test/unit/integrations/base_test.rb +99 -0
  90. data/test/unit/integrations/data_mapper_test.rb +1909 -0
  91. data/test/unit/integrations/mongo_mapper_test.rb +1611 -0
  92. data/test/unit/integrations/mongoid_test.rb +1591 -0
  93. data/test/unit/integrations/sequel_test.rb +1523 -0
  94. data/test/unit/integrations_test.rb +61 -0
  95. data/test/unit/invalid_event_test.rb +20 -0
  96. data/test/unit/invalid_parallel_transition_test.rb +18 -0
  97. data/test/unit/invalid_transition_test.rb +77 -0
  98. data/test/unit/machine_collection_test.rb +599 -0
  99. data/test/unit/machine_test.rb +3043 -0
  100. data/test/unit/matcher_helpers_test.rb +37 -0
  101. data/test/unit/matcher_test.rb +155 -0
  102. data/test/unit/node_collection_test.rb +217 -0
  103. data/test/unit/path_collection_test.rb +266 -0
  104. data/test/unit/path_test.rb +485 -0
  105. data/test/unit/state_collection_test.rb +310 -0
  106. data/test/unit/state_machine_test.rb +31 -0
  107. data/test/unit/state_test.rb +924 -0
  108. data/test/unit/transition_collection_test.rb +2102 -0
  109. data/test/unit/transition_test.rb +1541 -0
  110. metadata +207 -0
@@ -0,0 +1,4 @@
1
+ filename = "#{File.dirname(__FILE__)}/../active_model/locale.rb"
2
+ translations = eval(IO.read(filename), binding, filename)
3
+ translations[:en][:mongoid] = translations[:en].delete(:activemodel)
4
+ translations
@@ -0,0 +1,18 @@
1
+ module StateMachine
2
+ module Integrations #:nodoc:
3
+ module Mongoid
4
+ # Assumes Mongoid 2.1+ uses ActiveModel 3.1+
5
+ version '2.0.x' do
6
+ def self.active?
7
+ ::Mongoid::VERSION >= '2.0.0' && ::Mongoid::VERSION < '2.1.0'
8
+ end
9
+
10
+ def define_action_hook
11
+ # +around+ callbacks don't have direct access to results until AS 3.1
12
+ owner_class.set_callback(:save, :after, 'value', :prepend => true) if action_hook == :save
13
+ super
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,428 @@
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
+ include Base
220
+
221
+ require 'state_machine/integrations/sequel/versions'
222
+
223
+ # The default options to use for state machines using this integration
224
+ class << self; attr_reader :defaults; end
225
+ @defaults = {:action => :save}
226
+
227
+ # Whether this integration is available. Only true if Sequel::Model is
228
+ # defined.
229
+ def self.available?
230
+ defined?(::Sequel::Model)
231
+ end
232
+
233
+ # Should this integration be used for state machines in the given class?
234
+ # Classes that include Sequel::Model will automatically use the Sequel
235
+ # integration.
236
+ def self.matches?(klass)
237
+ klass <= ::Sequel::Model
238
+ end
239
+
240
+ # Forces the change in state to be recognized regardless of whether the
241
+ # state value actually changed
242
+ def write(object, attribute, value, *args)
243
+ result = super
244
+
245
+ column = self.attribute.to_sym
246
+ if (attribute == :state || attribute == :event && value) && owner_class.columns.include?(column) && !object.changed_columns.include?(column)
247
+ object.changed_columns << column
248
+ end
249
+
250
+ result
251
+ end
252
+
253
+ # Adds a validation error to the given object
254
+ def invalidate(object, attribute, message, values = [])
255
+ object.errors.add(self.attribute(attribute), generate_message(message, values))
256
+ end
257
+
258
+ # Resets any errors previously added when invalidating the given object
259
+ def reset(object)
260
+ object.errors.clear
261
+ end
262
+
263
+ # Pluralizes the name using the built-in inflector
264
+ def pluralize(word)
265
+ load_inflector
266
+ super
267
+ end
268
+
269
+ protected
270
+ # Loads the built-in inflector
271
+ def load_inflector
272
+ require 'sequel/extensions/inflector'
273
+ end
274
+
275
+ # Defines an initialization hook into the owner class for setting the
276
+ # initial state of the machine *before* any attributes are set on the
277
+ # object
278
+ def define_state_initializer
279
+ # Hooks in to attribute initialization to set the states *prior* to
280
+ # the attributes being set
281
+ define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1
282
+ # Initializes dynamic states
283
+ def initialize(*)
284
+ super do |*args|
285
+ self.class.state_machines.initialize_states(self, :static => false)
286
+ changed_columns.clear
287
+ yield(*args) if block_given?
288
+ end
289
+ end
290
+
291
+ # Initializes static states
292
+ def set(*)
293
+ self.class.state_machines.initialize_states(self, :dynamic => false) if values.empty?
294
+ super
295
+ end
296
+ end_eval
297
+ end
298
+
299
+ # Skips defining reader/writer methods since this is done automatically
300
+ def define_state_accessor
301
+ name = self.name
302
+ owner_class.validates_each(attribute) do |record, attr, value|
303
+ machine = record.class.state_machine(name)
304
+ machine.invalidate(record, :state, :invalid) unless machine.states.match(record)
305
+ end
306
+ end
307
+
308
+ # Adds hooks into validation for automatically firing events. This is
309
+ # a bit more complicated than other integrations since Sequel doesn't
310
+ # provide an easy way to hook around validation calls
311
+ def define_action_helpers
312
+ super
313
+
314
+ if action == :save
315
+ define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1
316
+ def valid?(*args)
317
+ yielded = false
318
+ result = self.class.state_machines.transitions(self, :save, :after => false).perform do
319
+ yielded = true
320
+ super
321
+ end
322
+
323
+ if yielded || result
324
+ result
325
+ else
326
+ #{handle_validation_failure}
327
+ end
328
+ end
329
+ end_eval
330
+ end
331
+ end
332
+
333
+ # Uses custom hooks for :save actions in order to preserve failure
334
+ # behavior within Sequel. This is a bit more complicated than other
335
+ # integrations since Sequel doesn't provide an easy way to hook around
336
+ # save calls.
337
+ def define_action_hook
338
+ if action == :save
339
+ define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1
340
+ def #{action_hook}(*)
341
+ yielded = false
342
+ result = self.class.state_machines.transitions(self, :save).perform do
343
+ yielded = true
344
+ super
345
+ end
346
+
347
+ if yielded || result
348
+ result
349
+ else
350
+ #{handle_save_failure}
351
+ end
352
+ end
353
+ end_eval
354
+ else
355
+ super
356
+ end
357
+ end
358
+
359
+ # Uses internal save hooks if using the :save action
360
+ def action_hook
361
+ action == :save ? :_save : super
362
+ end
363
+
364
+ # Handles whether validation errors should be raised
365
+ def handle_validation_failure
366
+ 'raise_on_failure?(args.first || {}) ? raise_hook_failure(:validation) : result'
367
+ end
368
+
369
+ # Handles how save failures are raised
370
+ def handle_save_failure
371
+ 'raise_hook_failure(:save)'
372
+ end
373
+
374
+ # Creates a scope for finding records *with* a particular state or
375
+ # states for the attribute
376
+ def create_with_scope(name)
377
+ create_scope(name, lambda {|dataset, values| dataset.filter(attribute_column => values)})
378
+ end
379
+
380
+ # Creates a scope for finding records *without* a particular state or
381
+ # states for the attribute
382
+ def create_without_scope(name)
383
+ create_scope(name, lambda {|dataset, values| dataset.exclude(attribute_column => values)})
384
+ end
385
+
386
+ # Creates a new named scope with the given name
387
+ def create_scope(name, scope)
388
+ machine = self
389
+ owner_class.def_dataset_method(name) do |*states|
390
+ machine.send(:run_scope, scope, self, states)
391
+ end
392
+
393
+ false
394
+ end
395
+
396
+ # Generates the results for the given scope based on one or more states to
397
+ # filter by
398
+ def run_scope(scope, dataset, states)
399
+ super(scope, model_from_dataset(dataset).state_machine(name), dataset, states)
400
+ end
401
+
402
+ # Determines the model associated with the given dataset
403
+ def model_from_dataset(dataset)
404
+ dataset.model
405
+ end
406
+
407
+ # Generates the fully-qualifed column name for this machine's attribute
408
+ def attribute_column
409
+ ::Sequel::SQL::QualifiedIdentifier.new(owner_class.table_name, attribute)
410
+ end
411
+
412
+ # Runs a new database transaction, rolling back any changes if the
413
+ # yielded block fails (i.e. returns false).
414
+ def transaction(object)
415
+ object.db.transaction {raise ::Sequel::Error::Rollback unless yield}
416
+ end
417
+
418
+ # Creates a new callback in the callback chain, always ensuring that
419
+ # it's configured to bind to the object as this is the convention for
420
+ # Sequel callbacks
421
+ def add_callback(type, options, &block)
422
+ options[:bind_to_object] = true
423
+ options[:terminator] = @terminator ||= lambda {|result| result == false}
424
+ super
425
+ end
426
+ end
427
+ end
428
+ end