diff --git a/README.md b/README.md index 11beb23e..ce41d368 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - [Extending AASM](#extending-aasm) - [ActiveRecord](#activerecord) - [Bang events](#bang-events) + - [Timestamps](#timestamps) - [ActiveRecord enums](#activerecord-enums) - [Sequel](#sequel) - [Dynamoid](#dynamoid) @@ -777,6 +778,37 @@ job.aasm_state = :running # => raises AASM::NoDirectAssignmentError job.aasm_state # => 'sleeping' ``` +### Timestamps + +You can tell _AASM_ to try to write a timestamp whenever a new state is entered. +If `timestamps: true` is set, _AASM_ will look for a field named like the new state plus `_at` and try to fill it: + +```ruby +class Job < ActiveRecord::Base + include AASM + + aasm timestamps: true do + state :sleeping, initial: true + state :running + + event :run do + transitions from: :sleeping, to: :running + end + end +end +``` + +resulting in this: + +```ruby +job = Job.create +job.running_at # => nil +job.run! +job.running_at # => 2020-02-20 20:00:00 +``` + +Missing timestamp fields are silently ignored, so it is not necessary to have setters (such as ActiveRecord columns) for *all* states when using this option. + #### ActiveRecord enums You can use diff --git a/lib/aasm/base.rb b/lib/aasm/base.rb index 82deef47..d660c30d 100644 --- a/lib/aasm/base.rb +++ b/lib/aasm/base.rb @@ -37,6 +37,9 @@ def initialize(klass, name, state_machine, options={}, &block) # string for a specific lock type i.e. FOR UPDATE NOWAIT configure :requires_lock, false + # automatically set `"#{state_name}_at" = ::Time.now` on state changes + configure :timestamps, false + # set to true to forbid direct assignment of aasm_state column (in ActiveRecord) configure :no_direct_assignment, false @@ -51,19 +54,12 @@ def initialize(klass, name, state_machine, options={}, &block) # Configure a logger, with default being a Logger to STDERR configure :logger, Logger.new(STDERR) + # setup timestamp-setting callback if enabled + setup_timestamps(@name) + # make sure to raise an error if no_direct_assignment is enabled # and attribute is directly assigned though - aasm_name = @name - - if @state_machine.config.no_direct_assignment - @klass.send(:define_method, "#{@state_machine.config.column}=") do |state_name| - if self.class.aasm(:"#{aasm_name}").state_machine.config.no_direct_assignment - raise AASM::NoDirectAssignmentError.new('direct assignment of AASM column has been disabled (see AASM configuration for this class)') - else - super(state_name) - end - end - end + setup_no_direct_assignment(@name) end # This method is both a getter and a setter @@ -267,5 +263,28 @@ def skip_instance_level_validation(event, name, aasm_name, klass) end end + def setup_timestamps(aasm_name) + return unless @state_machine.config.timestamps + + after_all_transitions do + if self.class.aasm(:"#{aasm_name}").state_machine.config.timestamps + ts_setter = "#{aasm.to_state}_at=" + respond_to?(ts_setter) && send(ts_setter, ::Time.now) + end + end + end + + def setup_no_direct_assignment(aasm_name) + return unless @state_machine.config.no_direct_assignment + + @klass.send(:define_method, "#{@state_machine.config.column}=") do |state_name| + if self.class.aasm(:"#{aasm_name}").state_machine.config.no_direct_assignment + raise AASM::NoDirectAssignmentError.new('direct assignment of AASM column has been disabled (see AASM configuration for this class)') + else + super(state_name) + end + end + end + end end diff --git a/lib/aasm/configuration.rb b/lib/aasm/configuration.rb index e32eb398..7b8f00dc 100644 --- a/lib/aasm/configuration.rb +++ b/lib/aasm/configuration.rb @@ -24,6 +24,9 @@ class Configuration # for ActiveRecord: use pessimistic locking attr_accessor :requires_lock + # automatically set `"#{state_name}_at" = ::Time.now` on state changes + attr_accessor :timestamps + # forbid direct assignment in aasm_state column (in ActiveRecord) attr_accessor :no_direct_assignment diff --git a/spec/database.rb b/spec/database.rb index b9987a3b..82236000 100644 --- a/spec/database.rb +++ b/spec/database.rb @@ -56,4 +56,9 @@ t.string "state" t.string "some_string" end + + ActiveRecord::Migration.create_table "timestamp_examples", :force => true do |t| + t.string "aasm_state" + t.datetime "opened_at" + end end diff --git a/spec/models/active_record/timestamp_example.rb b/spec/models/active_record/timestamp_example.rb new file mode 100644 index 00000000..4d59c0a0 --- /dev/null +++ b/spec/models/active_record/timestamp_example.rb @@ -0,0 +1,16 @@ +class TimestampExample < ActiveRecord::Base + include AASM + + aasm column: :aasm_state, timestamps: true do + state :opened + state :closed + + event :open do + transitions to: :opened + end + + event :close do + transitions to: :closed + end + end +end diff --git a/spec/models/mongoid/timestamp_example_mongoid.rb b/spec/models/mongoid/timestamp_example_mongoid.rb new file mode 100644 index 00000000..086dc3b4 --- /dev/null +++ b/spec/models/mongoid/timestamp_example_mongoid.rb @@ -0,0 +1,20 @@ +class TimestampExampleMongoid + include Mongoid::Document + include AASM + + field :status, type: String + field :opened_at, type: Time + + aasm column: :status, timestamps: true do + state :opened + state :closed + + event :open do + transitions to: :opened + end + + event :close do + transitions to: :closed + end + end +end diff --git a/spec/models/timestamps_example.rb b/spec/models/timestamps_example.rb new file mode 100644 index 00000000..4669fbf5 --- /dev/null +++ b/spec/models/timestamps_example.rb @@ -0,0 +1,19 @@ +class TimestampsExample + include AASM + + attr_accessor :opened_at + attr_reader :closed_at + + aasm timestamps: true do + state :opened + state :closed + + event :open do + transitions to: :opened + end + + event :close do + transitions to: :closed + end + end +end diff --git a/spec/unit/persistence/active_record_persistence_spec.rb b/spec/unit/persistence/active_record_persistence_spec.rb index 09781f44..41d2ea3e 100644 --- a/spec/unit/persistence/active_record_persistence_spec.rb +++ b/spec/unit/persistence/active_record_persistence_spec.rb @@ -770,4 +770,16 @@ expect(example.complete!).to be_falsey end end + + describe 'testing the timestamps option' do + let(:example) { TimestampExample.create! } + + it 'should update existing timestamp columns' do + expect { example.open! }.to change { example.reload.opened_at }.from(nil).to(instance_of(::Time)) + end + + it 'should not fail if there is no corresponding timestamp column' do + expect { example.close! }.to change { example.reload.aasm_state } + end + end end diff --git a/spec/unit/persistence/mongoid_persistence_spec.rb b/spec/unit/persistence/mongoid_persistence_spec.rb index 1f5257c4..1147117b 100644 --- a/spec/unit/persistence/mongoid_persistence_spec.rb +++ b/spec/unit/persistence/mongoid_persistence_spec.rb @@ -161,5 +161,17 @@ end end + describe 'testing the timestamps option' do + let(:example) { TimestampExampleMongoid.create } + + it 'should update existing timestamp fields' do + expect { example.open! }.to change { example.reload.opened_at }.from(nil).to(instance_of(::Time)) + end + + it 'should not fail if there is no corresponding timestamp field' do + expect { example.close! }.to change { example.reload.status } + end + end + end end diff --git a/spec/unit/timestamps_spec.rb b/spec/unit/timestamps_spec.rb new file mode 100644 index 00000000..1912589f --- /dev/null +++ b/spec/unit/timestamps_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe 'timestamps option' do + it 'calls a timestamp setter based on the state name when entering a new state' do + object = TimestampsExample.new + expect { object.open }.to change { object.opened_at }.from(nil).to(instance_of(::Time)) + end + + it 'overwrites any previous timestamp if a state is entered repeatedly' do + object = TimestampsExample.new + object.opened_at = ::Time.new(2000, 1, 1) + expect { object.open }.to change { object.opened_at } + end + + it 'does nothing if there is no setter matching the new state' do + object = TimestampsExample.new + expect { object.close }.not_to change { object.closed_at } + end + + it 'can be turned off and on' do + object = TimestampsExample.new + object.class.aasm.state_machine.config.timestamps = false + expect { object.open }.not_to change { object.opened_at } + object.class.aasm.state_machine.config.timestamps = true + expect { object.open }.to change { object.opened_at } + end +end