state_machine 0.8.0 → 0.8.1

Sign up to get free protection for your applications and to get access to all the features.
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
  #