state_machine 0.8.0 → 0.8.1

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.
data/CHANGELOG.rdoc CHANGED
@@ -1,5 +1,24 @@
1
1
  == master
2
2
 
3
+ == 0.8.1 / 2010-03-14
4
+
5
+ * Release gems via rake-gemcutter instead of rubyforge
6
+ * Move rake tasks to lib/tasks
7
+ * Dispatch state behavior to the superclass if it's undefined for a particular state [Sandro Turriate and Tim Pope]
8
+ * Fix state / event names not supporting i18n in ActiveRecord
9
+ * Fix original ActiveRecord::Observer#update not being used for non-state_machine callbacks [Jeremy Wells]
10
+ * Add support for ActiveRecord 3.0
11
+ * Fix without_{name} scopes not quoting columns in ActiveRecord [Jon Evans]
12
+ * Fix without_{name} scopes not scoping columns to the table in ActiveRecord and Sequel [Jon Evans]
13
+ * Fix custom state attributes not being marked properly as changed in ActiveRecord
14
+ * Fix tracked attributes changes in ActiveRecord / DataMapper integrations not working correctly for non-loopbacks [Joe Lind]
15
+ * Fix plural scope names being incorrect for DataMapper 0.9.4 - 0.9.6
16
+ * Fix deprecation warnings for ruby-graphviz 0.9.0+
17
+ * Add support for ActiveRecord 2.0.*
18
+ * Fix nil states being overwritten when they're explicitly set in ORM integrations
19
+ * Fix default states not getting set in ORM integrations if the column has a default
20
+ * Fix event transitions being kept around while running actions/callbacks, sometimes preventing object marshalling
21
+
3
22
  == 0.8.0 / 2009-08-15
4
23
 
5
24
  * Add support for DataMapper 0.10.0
data/README.rdoc CHANGED
@@ -427,6 +427,16 @@ For example,
427
427
 
428
428
  rake state_machine:draw:rails CLASS=Vehicle
429
429
 
430
+ If you are using this library as a gem, the following must be added to the end
431
+ of your application's Rakefile in order for the above task to work:
432
+
433
+ require 'tasks/state_machine'
434
+
435
+ If you are using Rails 3.0+, you must also add the following to your
436
+ application's Gemfile:
437
+
438
+ gem 'ruby-graphviz', :require => 'graphviz'
439
+
430
440
  ==== Merb Integration
431
441
 
432
442
  Like Ruby on Rails, there is a special integration Rake task for generating
@@ -454,13 +464,23 @@ integrations if the proper dependencies are available):
454
464
 
455
465
  Target specific versions of integrations like so:
456
466
 
457
- rake test AR_VERSION=2.1.0 DM_VERSION=0.9.4 SEQUEL_VERSION=2.8.0
467
+ rake test AR_VERSION=2.0.0 DM_VERSION=0.9.4 SEQUEL_VERSION=2.8.0
468
+
469
+ == Caveats
470
+
471
+ The following caveats should be noted when using state_machine:
472
+
473
+ * DataMapper: dm-validations 0.9.4 - 0.9.6 causes +after+ callbacks for
474
+ attribute-based event transitions to fail
475
+ * Overridden event methods won't get invoked when using attribute-based event
476
+ transitions
458
477
 
459
478
  == Dependencies
460
479
 
461
- By default, there are no dependencies. If using specific integrations, those
462
- dependencies are listed below.
480
+ * Ruby 1.8.6 or later
481
+
482
+ If using specific integrations:
463
483
 
464
- * ActiveRecord[http://rubyonrails.org] integration: 2.1.0 or later
484
+ * ActiveRecord[http://rubyonrails.org] integration: 2.0.0 or later
465
485
  * DataMapper[http://datamapper.org] integration: 0.9.4 or later
466
486
  * Sequel[http://sequel.rubyforge.org] integration: 2.8.0 or later
data/Rakefile CHANGED
@@ -1,16 +1,17 @@
1
+ require 'rubygems'
2
+ require 'rake'
1
3
  require 'rake/testtask'
2
4
  require 'rake/rdoctask'
3
5
  require 'rake/gempackagetask'
4
- require 'rake/contrib/sshpublisher'
5
6
 
6
7
  spec = Gem::Specification.new do |s|
7
8
  s.name = 'state_machine'
8
- s.version = '0.8.0'
9
+ s.version = '0.8.1'
9
10
  s.platform = Gem::Platform::RUBY
10
11
  s.summary = 'Adds support for creating state machines for attributes on any Ruby class'
11
12
  s.description = s.summary
12
13
 
13
- s.files = FileList['{examples,lib,tasks,test}/**/*'] + %w(CHANGELOG.rdoc init.rb LICENSE Rakefile README.rdoc) - FileList['test/*.log']
14
+ s.files = FileList['{examples,lib,test}/**/*'] + %w(CHANGELOG.rdoc init.rb LICENSE Rakefile README.rdoc) - FileList['test/*.log']
14
15
  s.require_path = 'lib'
15
16
  s.has_rdoc = true
16
17
  s.test_files = Dir['test/**/*_test.rb']
@@ -63,17 +64,17 @@ end
63
64
 
64
65
  Rake::GemPackageTask.new(spec) do |p|
65
66
  p.gem_spec = spec
66
- p.need_tar = true
67
- p.need_zip = true
68
67
  end
69
68
 
70
69
  desc 'Publish the beta gem.'
71
70
  task :pgem => [:package] do
71
+ require 'rake/contrib/sshpublisher'
72
72
  Rake::SshFilePublisher.new('aaron@pluginaweek.org', '/home/aaron/gems.pluginaweek.org/public/gems', 'pkg', "#{spec.name}-#{spec.version}.gem").upload
73
73
  end
74
74
 
75
75
  desc 'Publish the API documentation.'
76
76
  task :pdoc => [:rdoc] do
77
+ require 'rake/contrib/sshpublisher'
77
78
  Rake::SshDirPublisher.new('aaron@pluginaweek.org', "/home/aaron/api.pluginaweek.org/public/#{spec.name}", 'rdoc').upload
78
79
  end
79
80
 
@@ -82,17 +83,10 @@ task :publish => [:pgem, :pdoc, :release]
82
83
 
83
84
  desc 'Publish the release files to RubyForge.'
84
85
  task :release => [:gem, :package] do
85
- require 'rubyforge'
86
+ require 'rake/gemcutter'
86
87
 
87
- ruby_forge = RubyForge.new.configure
88
- ruby_forge.login
89
-
90
- %w(gem tgz zip).each do |ext|
91
- file = "pkg/#{spec.name}-#{spec.version}.#{ext}"
92
- puts "Releasing #{File.basename(file)}..."
93
-
94
- ruby_forge.add_release(spec.rubyforge_project, spec.name, spec.version, file)
95
- end
88
+ Rake::Gemcutter::Tasks.new(spec)
89
+ Rake::Task['gem:push'].invoke
96
90
  end
97
91
 
98
- Dir['tasks/**/*.rake'].each {|rake| load rake}
92
+ load 'lib/tasks/state_machine.rake'
data/lib/state_machine.rb CHANGED
@@ -385,4 +385,4 @@ Class.class_eval do
385
385
  end
386
386
 
387
387
  # Register rake tasks for supported libraries
388
- Merb::Plugins.add_rakefiles("#{File.dirname(__FILE__)}/../tasks/state_machine") if defined?(Merb::Plugins)
388
+ Merb::Plugins.add_rakefiles("#{File.dirname(__FILE__)}/tasks/state_machine") if defined?(Merb::Plugins)
@@ -102,19 +102,17 @@ module StateMachine
102
102
  def attribute_transition_for(object, invalidate = false)
103
103
  return unless machine.action
104
104
 
105
- result = nil
106
-
107
- if event_name = machine.read(object, :event)
105
+ result = machine.read(object, :event_transition) || if event_name = machine.read(object, :event)
108
106
  if event = self[event_name.to_sym, :name]
109
- unless result = machine.read(object, :event_transition) || event.transition_for(object)
107
+ event.transition_for(object) || begin
110
108
  # No valid transition: invalidate
111
109
  machine.invalidate(object, :event, :invalid_event, [[:state, machine.states.match!(object).name || 'nil']]) if invalidate
112
- result = false
110
+ false
113
111
  end
114
112
  else
115
113
  # Event is unknown: invalidate
116
114
  machine.invalidate(object, :event, :invalid) if invalidate
117
- result = false
115
+ false
118
116
  end
119
117
  end
120
118
 
@@ -259,6 +259,50 @@ module StateMachine
259
259
  # Audit.log(record, transition)
260
260
  # end
261
261
  # end
262
+ #
263
+ # == Internationalization
264
+ #
265
+ # In Rails 2.2+, any error message that is generated from performing invalid
266
+ # transitions can be localized. The following default translations are used:
267
+ #
268
+ # en:
269
+ # activerecord:
270
+ # errors:
271
+ # messages:
272
+ # invalid: "is invalid"
273
+ # invalid_event: "cannot transition when {{state}}"
274
+ # invalid_transition: "cannot transition via {{event}}"
275
+ #
276
+ # You can override these for a specific model like so:
277
+ #
278
+ # en:
279
+ # activerecord:
280
+ # errors:
281
+ # models:
282
+ # user:
283
+ # invalid: "is not valid"
284
+ #
285
+ # In addition to the above, you can also provide translations for the
286
+ # various states / events in each state machine. Using the Vehicle example,
287
+ # state translations will be looked for using the following keys:
288
+ # * <tt>activerecord.state_machines.vehicle.state.states.parked</tt>
289
+ # * <tt>activerecord.state_machines.state.states.parked
290
+ # * <tt>activerecord.state_machines.states.parked</tt>
291
+ #
292
+ # Event translations will be looked for using the following keys:
293
+ # * <tt>activerecord.state_machines.vehicle.state.events.ignite</tt>
294
+ # * <tt>activerecord.state_machines.state.events.ignite
295
+ # * <tt>activerecord.state_machines.events.ignite</tt>
296
+ #
297
+ # An example translation configuration might look like so:
298
+ #
299
+ # es:
300
+ # activerecord:
301
+ # state_machines:
302
+ # states:
303
+ # parked: 'estacionado'
304
+ # events:
305
+ # park: 'estacionarse'
262
306
  module ActiveRecord
263
307
  # The default options to use for state machines using this integration
264
308
  class << self; attr_reader :defaults; end
@@ -285,7 +329,9 @@ module StateMachine
285
329
  # state value actually changed
286
330
  def write(object, attribute, value)
287
331
  result = super
288
- object.send("#{self.attribute}_will_change!") if attribute == :state && object.respond_to?("#{self.attribute}_will_change!")
332
+ if attribute == :state && object.respond_to?("#{self.attribute}_will_change!") && !object.send("#{self.attribute}_changed?")
333
+ object.send("#{self.attribute}_will_change!")
334
+ end
289
335
  result
290
336
  end
291
337
 
@@ -294,10 +340,26 @@ module StateMachine
294
340
  attribute = self.attribute(attribute)
295
341
 
296
342
  if Object.const_defined?(:I18n)
297
- options = values.inject({}) {|options, (key, value)| options[key] = value; options}
298
- object.errors.add(attribute, message, options.merge(
299
- :default => @messages[message]
300
- ))
343
+ klasses =
344
+ if ::ActiveRecord::VERSION::MAJOR >= 3
345
+ object.class.lookup_ancestors
346
+ elsif ::ActiveRecord::VERSION::MINOR == 3 && ::ActiveRecord::VERSION::TINY >= 2
347
+ object.class.self_and_descendants_from_active_record
348
+ else
349
+ object.class.self_and_descendents_from_active_record
350
+ end
351
+
352
+ options = values.inject({}) do |options, (key, value)|
353
+ # Generate all possible translation keys
354
+ group = key.to_s.pluralize
355
+ translations = klasses.map {|klass| :"#{klass.model_name.underscore}.#{name}.#{group}.#{value}"}
356
+ translations.concat([:"#{name}.#{group}.#{value}", :"#{group}.#{value}", value.to_s])
357
+
358
+ options[key] = I18n.translate(translations.shift, :default => translations, :scope => [:activerecord, :state_machines])
359
+ options
360
+ end
361
+
362
+ object.errors.add(attribute, message, options.merge(:default => @messages[message]))
301
363
  else
302
364
  object.errors.add(attribute, generate_message(message, values))
303
365
  end
@@ -330,13 +392,21 @@ module StateMachine
330
392
 
331
393
  # Hooks in to attribute initialization to set the states *prior*
332
394
  # to the attributes being set
333
- def attributes=(*args)
395
+ def attributes=(new_attributes, *args)
334
396
  if new_record? && !@initialized_state_machines
335
397
  @initialized_state_machines = true
336
398
 
337
- initialize_state_machines(:dynamic => false)
399
+ if new_attributes
400
+ attributes = new_attributes.dup
401
+ attributes.stringify_keys!
402
+ ignore = remove_attributes_protected_from_mass_assignment(attributes).keys
403
+ else
404
+ ignore = []
405
+ end
406
+
407
+ initialize_state_machines(:dynamic => false, :ignore => ignore)
338
408
  super
339
- initialize_state_machines(:dynamic => true)
409
+ initialize_state_machines(:dynamic => true, :ignore => ignore)
340
410
  else
341
411
  super
342
412
  end
@@ -387,15 +457,16 @@ module StateMachine
387
457
  # Creates a scope for finding records *with* a particular state or
388
458
  # states for the attribute
389
459
  def create_with_scope(name)
390
- attribute = self.attribute
391
460
  define_scope(name, lambda {|values| {:conditions => {attribute => values}}})
392
461
  end
393
462
 
394
463
  # Creates a scope for finding records *without* a particular state or
395
464
  # states for the attribute
396
465
  def create_without_scope(name)
397
- attribute = self.attribute
398
- define_scope(name, lambda {|values| {:conditions => ["#{attribute} NOT IN (?)", values]}})
466
+ define_scope(name, lambda {|values|
467
+ connection = owner_class.connection
468
+ {:conditions => ["#{connection.quote_table_name(owner_class.table_name)}.#{connection.quote_column_name(attribute)} NOT IN (?)", values]}
469
+ })
399
470
  end
400
471
 
401
472
  # Runs a new database transaction, rolling back any changes by raising
@@ -424,20 +495,26 @@ module StateMachine
424
495
  # state names can be translated to their associated values and so that
425
496
  # inheritance is respected properly.
426
497
  def define_scope(name, scope)
427
- name = name.to_sym
428
- machine_name = self.name
429
-
430
- # Create the scope and then override it with state translation
431
- owner_class.named_scope(name)
432
- owner_class.scopes[name] = lambda do |klass, *states|
433
- machine_states = klass.state_machine(machine_name).states
434
- values = states.flatten.map {|state| machine_states.fetch(state).value}
498
+ if ::ActiveRecord::VERSION::MAJOR <= 2
499
+ if owner_class.respond_to?(:named_scope)
500
+ name = name.to_sym
501
+ machine_name = self.name
502
+
503
+ # Create the scope and then override it with state translation
504
+ owner_class.named_scope(name)
505
+ owner_class.scopes[name] = lambda do |klass, *states|
506
+ machine_states = klass.state_machine(machine_name).states
507
+ values = states.flatten.map {|state| machine_states.fetch(state).value}
508
+
509
+ ::ActiveRecord::NamedScope::Scope.new(klass, scope.call(values))
510
+ end
511
+ end
435
512
 
436
- ::ActiveRecord::NamedScope::Scope.new(klass, scope.call(values))
513
+ # Prevent the Machine class from wrapping the scope
514
+ false
515
+ else
516
+ lambda {|klass, values| klass.where(scope.call(values)[:conditions])}
437
517
  end
438
-
439
- # Prevent the Machine class from wrapping the scope
440
- false
441
518
  end
442
519
 
443
520
  # Notifies observers on the given object that a callback occurred
@@ -29,7 +29,11 @@ module StateMachine
29
29
  # Allows additional arguments other than the object to be passed to the
30
30
  # observed methods
31
31
  def update_with_multiple_args(observed_method, object, *args) #:nodoc:
32
- send(observed_method, object, *args) if respond_to?(observed_method)
32
+ if args.any?
33
+ send(observed_method, object, *args) if respond_to?(observed_method)
34
+ else
35
+ update_without_multiple_args(observed_method, object)
36
+ end
33
37
  end
34
38
  end
35
39
  end
@@ -257,9 +257,10 @@ module StateMachine
257
257
  result = super
258
258
  if attribute == :state && owner_class.properties.detect {|property| property.name == self.attribute}
259
259
  if ::DataMapper::VERSION =~ /^(0\.\d\.)/ # Match anything < 0.10
260
- object.original_values[self.attribute] = "#{value}-ignored"
260
+ object.original_values[self.attribute] = "#{value}-ignored" if object.original_values[self.attribute] == value
261
261
  else
262
- object.original_attributes[owner_class.properties[self.attribute]] = "#{value}-ignored"
262
+ property = owner_class.properties[self.attribute]
263
+ object.original_attributes[property] = "#{value}-ignored" unless object.original_attributes.include?(property)
263
264
  end
264
265
  end
265
266
  result
@@ -281,6 +282,25 @@ module StateMachine
281
282
  @supports_validations ||= ::DataMapper.const_defined?('Validate')
282
283
  end
283
284
 
285
+ # Pluralizes the name using the built-in inflector
286
+ def pluralize(word)
287
+ Extlib::Inflection.pluralize(word.to_s)
288
+ end
289
+
290
+ # Defines an initialization hook into the owner class for setting the
291
+ # initial state of the machine *before* any attributes are set on the
292
+ # object
293
+ def define_state_initializer
294
+ @instance_helper_module.class_eval <<-end_eval, __FILE__, __LINE__
295
+ def initialize(attributes = {}, *args)
296
+ ignore = attributes ? attributes.keys : []
297
+ initialize_state_machines(:dynamic => false, :ignore => ignore)
298
+ super
299
+ initialize_state_machines(:dynamic => true, :ignore => ignore)
300
+ end
301
+ end_eval
302
+ end
303
+
284
304
  # Skips defining reader/writer methods since this is done automatically
285
305
  def define_state_accessor
286
306
  owner_class.property(attribute, String) unless owner_class.properties.detect {|property| property.name == attribute}
@@ -308,14 +328,12 @@ module StateMachine
308
328
  # Creates a scope for finding records *with* a particular state or
309
329
  # states for the attribute
310
330
  def create_with_scope(name)
311
- attribute = self.attribute
312
331
  lambda {|resource, values| resource.all(attribute => values)}
313
332
  end
314
333
 
315
334
  # Creates a scope for finding records *without* a particular state or
316
335
  # states for the attribute
317
336
  def create_without_scope(name)
318
- attribute = self.attribute
319
337
  lambda {|resource, values| resource.all(attribute.to_sym.not => values)}
320
338
  end
321
339
 
@@ -253,13 +253,14 @@ module StateMachine
253
253
  @instance_helper_module.class_eval <<-end_eval, __FILE__, __LINE__
254
254
  # Hooks in to attribute initialization to set the states *prior*
255
255
  # to the attributes being set
256
- def set(*args)
256
+ def set(hash, *args)
257
257
  if new? && !@initialized_state_machines
258
258
  @initialized_state_machines = true
259
259
 
260
- initialize_state_machines(:dynamic => false)
260
+ ignore = setter_methods(nil, nil).map {|setter| setter.chop.to_sym} & (hash ? hash.keys.map {|attribute| attribute.to_sym} : [])
261
+ initialize_state_machines(:dynamic => false, :ignore => ignore)
261
262
  result = super
262
- initialize_state_machines(:dynamic => true)
263
+ initialize_state_machines(:dynamic => true, :ignore => ignore)
263
264
  result
264
265
  else
265
266
  super
@@ -291,15 +292,13 @@ module StateMachine
291
292
  # Creates a scope for finding records *with* a particular state or
292
293
  # states for the attribute
293
294
  def create_with_scope(name)
294
- attribute = self.attribute
295
- lambda {|model, values| model.filter(attribute.to_sym => values)}
295
+ lambda {|model, values| model.filter(:"#{owner_class.table_name}__#{attribute}" => values)}
296
296
  end
297
297
 
298
298
  # Creates a scope for finding records *without* a particular state or
299
299
  # states for the attribute
300
300
  def create_without_scope(name)
301
- attribute = self.attribute
302
- lambda {|model, values| model.filter(~{attribute.to_sym => values})}
301
+ lambda {|model, values| model.filter(~{:"#{owner_class.table_name}__#{attribute}" => values})}
303
302
  end
304
303
 
305
304
  # Runs a new database transaction, rolling back any changes if the
@@ -1258,21 +1258,16 @@ module StateMachine
1258
1258
  :path => '.',
1259
1259
  :format => 'png',
1260
1260
  :font => 'Arial',
1261
- :orientation => 'portrait',
1262
- :output => true
1261
+ :orientation => 'portrait'
1263
1262
  }.merge(options)
1264
- assert_valid_keys(options, :name, :path, :format, :font, :orientation, :output)
1263
+ assert_valid_keys(options, :name, :path, :format, :font, :orientation)
1265
1264
 
1266
1265
  begin
1267
1266
  # Load the graphviz library
1268
1267
  require 'rubygems'
1269
1268
  require 'graphviz'
1270
1269
 
1271
- graph = GraphViz.new('G',
1272
- :output => options[:format],
1273
- :file => File.join(options[:path], "#{options[:name]}.#{options[:format]}"),
1274
- :rankdir => options[:orientation] == 'landscape' ? 'LR' : 'TB'
1275
- )
1270
+ graph = GraphViz.new('G', :rankdir => options[:orientation] == 'landscape' ? 'LR' : 'TB')
1276
1271
 
1277
1272
  # Add nodes
1278
1273
  states.by_priority.each do |state|
@@ -1287,7 +1282,20 @@ module StateMachine
1287
1282
  end
1288
1283
 
1289
1284
  # Generate the graph
1290
- graph.output if options[:output]
1285
+ graphvizVersion = Constants::RGV_VERSION.split('.')
1286
+
1287
+ if graphvizVersion[0] == '0' && (graphvizVersion[1] < '9' || graphvizVersion[1] == '9' && graphvizVersion[2] == '0')
1288
+ outputOptions = {
1289
+ :output => options[:format],
1290
+ :file => File.join(options[:path], "#{options[:name]}.#{options[:format]}")
1291
+ }
1292
+ else
1293
+ outputOptions = {
1294
+ options[:format] => File.join(options[:path], "#{options[:name]}.#{options[:format]}")
1295
+ }
1296
+ end
1297
+
1298
+ graph.output(outputOptions)
1291
1299
  graph
1292
1300
  rescue LoadError
1293
1301
  $stderr.puts 'Cannot draw the machine. `gem install ruby-graphviz` and try again.'
@@ -1408,7 +1416,7 @@ module StateMachine
1408
1416
  # automatically determined by either calling +pluralize+ on the attribute
1409
1417
  # name or adding an "s" to the end of the name.
1410
1418
  def define_scopes(custom_plural = nil)
1411
- plural = custom_plural || (name.to_s.respond_to?(:pluralize) ? name.to_s.pluralize : "#{name}s")
1419
+ plural = custom_plural || pluralize(name)
1412
1420
 
1413
1421
  [name, plural].uniq.each do |name|
1414
1422
  [:with, :without].each do |kind|
@@ -1426,6 +1434,17 @@ module StateMachine
1426
1434
  end
1427
1435
  end
1428
1436
 
1437
+ # Pluralizes the given word using #pluralize (if available) or simply
1438
+ # adding an "s" to the end of the word
1439
+ def pluralize(word)
1440
+ word = word.to_s
1441
+ if word.respond_to?(:pluralize)
1442
+ word.pluralize
1443
+ else
1444
+ "#{name}s"
1445
+ end
1446
+ end
1447
+
1429
1448
  # Creates a scope for finding objects *with* a particular value or values
1430
1449
  # for the attribute.
1431
1450
  #