Skip to content

Commit

Permalink
Add simple option for auto-generated timestamps
Browse files Browse the repository at this point in the history
closes #418
closes #427
  • Loading branch information
jaynetics authored and Anil Kumar Maurya committed Mar 8, 2021
1 parent 151c6fc commit 143eedc
Show file tree
Hide file tree
Showing 10 changed files with 176 additions and 11 deletions.
32 changes: 32 additions & 0 deletions README.md
Expand Up @@ -20,6 +20,7 @@
- [Extending AASM](#extending-aasm)
- [ActiveRecord](#activerecord)
- [Bang events](#bang-events)
- [Timestamps](#timestamps)
- [ActiveRecord enums](#activerecord-enums)
- [Sequel](#sequel)
- [Dynamoid](#dynamoid)
Expand Down Expand Up @@ -821,6 +822,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
Expand Down
41 changes: 30 additions & 11 deletions lib/aasm/base.rb
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions lib/aasm/configuration.rb
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions spec/database.rb
Expand Up @@ -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
16 changes: 16 additions & 0 deletions 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
20 changes: 20 additions & 0 deletions 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
19 changes: 19 additions & 0 deletions 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
12 changes: 12 additions & 0 deletions spec/unit/persistence/active_record_persistence_spec.rb
Expand Up @@ -837,4 +837,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
12 changes: 12 additions & 0 deletions spec/unit/persistence/mongoid_persistence_spec.rb
Expand Up @@ -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
27 changes: 27 additions & 0 deletions 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

0 comments on commit 143eedc

Please sign in to comment.