Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add simple option for auto-generated timestamps #677

Merged
merged 1 commit into from Mar 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@ -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
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 @@ -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
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