1
0
Fork 0
mirror of https://github.com/aasm/aasm synced 2023-03-27 23:22:41 -04:00

Add Sequel transactions and locking

* Add aasm features of ActiveRecord to Sequel! :)
 * All specs pass for sequel, activerecord, and mongoid.
 * Refactor Sequel models to be namespaced under “Sequel”. We
   should do something similar for ActiveRecord in the specs
   to avoid collisions.

Addresses #474

(cherry picked from commit df97ce754f47864abbcb837227da5c4d0ff77289)
This commit is contained in:
Aryk Grosz 2017-06-13 10:00:34 -07:00 committed by Anil Kumar Maurya
parent 2c49078c70
commit 0c4a25a8d4
11 changed files with 663 additions and 96 deletions

View file

@ -1,8 +1,10 @@
require 'aasm/persistence/orm'
module AASM
module Persistence
module SequelPersistence
def self.included(base)
base.send(:include, AASM::Persistence::Base)
base.send(:include, AASM::Persistence::ORM)
base.send(:include, AASM::Persistence::SequelPersistence::InstanceMethods)
end
@ -17,42 +19,42 @@ module AASM
super
end
# Returns the value of the aasm.attribute_name - called from <tt>aasm.current_state</tt>
#
# If it's a new record, and the aasm state column is blank it returns the initial state
#
# class Foo < Sequel::Model
# include AASM
# aasm :column => :status do
# state :opened
# state :closed
# end
# end
#
# foo = Foo.new
# foo.current_state # => :opened
# foo.close
# foo.current_state # => :closed
#
# foo = Foo[1]
# foo.current_state # => :opened
# foo.aasm_state = nil
# foo.current_state # => nil
#
# NOTE: intended to be called from an event
#
# This allows for nil aasm states - be sure to add validation to your model
def aasm_read_state(name=:default)
state = send(self.class.aasm(name).attribute_name)
if new? && state.to_s.strip.empty?
aasm(name).determine_state_name(self.class.aasm(name).initial_state)
elsif state.nil?
nil
else
state.to_sym
def aasm_raise_invalid_record
raise Sequel::ValidationFailed.new(self)
end
def aasm_new_record?
new?
end
# Returns nil if fails silently
# http://sequel.jeremyevans.net/rdoc/classes/Sequel/Model/InstanceMethods.html#method-i-save
def aasm_save
!save(raise_on_failure: false).nil?
end
def aasm_read_attribute(name)
send(name)
end
def aasm_write_attribute(name, value)
send("#{name}=", value)
end
def aasm_transaction(requires_new, requires_lock)
self.class.db.transaction(savepoint: requires_new) do
if requires_lock
# http://sequel.jeremyevans.net/rdoc/classes/Sequel/Model/InstanceMethods.html#method-i-lock-21
requires_lock.is_a?(String) ? lock!(requires_lock) : lock!
end
yield
end
end
def aasm_update_column(attribute_name, value)
this.update(attribute_name => value)
end
# Ensures that if the aasm_state column is nil and the record is new
# that the initial state gets populated before validation on create
#
@ -72,39 +74,10 @@ module AASM
AASM::StateMachineStore.fetch(self.class, true).machine_names.each do |state_machine_name|
aasm(state_machine_name).enter_initial_state if
(new? || values.key?(self.class.aasm(state_machine_name).attribute_name)) &&
send(self.class.aasm(state_machine_name).attribute_name).to_s.strip.empty?
send(self.class.aasm(state_machine_name).attribute_name).to_s.strip.empty?
end
end
# Writes <tt>state</tt> to the state column and persists it to the database
#
# foo = Foo[1]
# foo.aasm.current_state # => :opened
# foo.close!
# foo.aasm.current_state # => :closed
# Foo[1].aasm.current_state # => :closed
#
# NOTE: intended to be called from an event
def aasm_write_state state, name=:default
aasm_column = self.class.aasm(name).attribute_name
update_fields({aasm_column => state.to_s}, [aasm_column], missing: :skip)
end
# Writes <tt>state</tt> to the state column, but does not persist it to the database
#
# foo = Foo[1]
# foo.aasm.current_state # => :opened
# foo.close
# foo.aasm.current_state # => :closed
# Foo[1].aasm.current_state # => :opened
# foo.save
# foo.aasm.current_state # => :closed
# Foo[1].aasm.current_state # => :closed
#
# NOTE: intended to be called from an event
def aasm_write_state_without_persistence state, name=:default
send("#{self.class.aasm(name).attribute_name}=", state.to_s)
end
end
end
end

View file

@ -1,4 +1,4 @@
db = Sequel.connect(SEQUEL_DB)
db = Sequel::DATABASES.first || Sequel.connect(SEQUEL_DB)
# if you want to see the statements while running the spec enable the following line
# db.loggers << Logger.new($stderr)
@ -8,8 +8,8 @@ db.create_table(:complex_sequel_examples) do
String :right
end
class ComplexSequelExample < Sequel::Model(db)
set_dataset(:complex_sequel_examples)
module Sequel
class ComplexExample < Sequel::Model(:complex_sequel_examples)
include AASM
@ -43,3 +43,4 @@ class ComplexSequelExample < Sequel::Model(db)
end
end
end

View file

@ -0,0 +1,52 @@
db = Sequel::DATABASES.first || Sequel.connect(SEQUEL_DB)
[:invalid_persistors, :multiple_invalid_persistors].each do |table_name|
db.create_table(table_name) do
primary_key :id
String "name"
String "status"
end
end
module Sequel
class InvalidPersistor < Sequel::Model(:invalid_persistors)
plugin :validation_helpers
include AASM
aasm :column => :status, :skip_validation_on_save => true do
state :sleeping, :initial => true
state :running
event :run do
transitions :to => :running, :from => :sleeping
end
event :sleep do
transitions :to => :sleeping, :from => :running
end
end
def validate
super
validates_presence :name
end
end
class MultipleInvalidPersistor < Sequel::Model(:multiple_invalid_persistors)
plugin :validation_helpers
include AASM
aasm :left, :column => :status, :skip_validation_on_save => true do
state :sleeping, :initial => true
state :running
event :run do
transitions :to => :running, :from => :sleeping
end
event :sleep do
transitions :to => :sleeping, :from => :running
end
end
def validate
super
validates_presence :name
end
end
end

View file

@ -1,4 +1,4 @@
db = Sequel.connect(SEQUEL_DB)
db = Sequel::DATABASES.first || Sequel.connect(SEQUEL_DB)
# if you want to see the statements while running the spec enable the following line
# db.loggers << Logger.new($stderr)
@ -6,20 +6,20 @@ db.create_table(:multiples) do
primary_key :id
String :status
end
module Sequel
class Multiple < Sequel::Model(:multiples)
include AASM
class SequelMultiple < Sequel::Model(db)
set_dataset(:multiples)
include AASM
attr_accessor :default
attr_accessor :default
aasm :left, :column => :status
aasm :left do
state :alpha, :initial => true
state :beta
state :gamma
event :release do
transitions :from => [:alpha, :beta, :gamma], :to => :beta
aasm :left, :column => :status
aasm :left do
state :alpha, :initial => true
state :beta
state :gamma
event :release do
transitions :from => [:alpha, :beta, :gamma], :to => :beta
end
end
end
end

View file

@ -1,4 +1,4 @@
db = Sequel.connect(SEQUEL_DB)
db = Sequel::DATABASES.first || Sequel.connect(SEQUEL_DB)
# if you want to see the statements while running the spec enable the following line
# db.loggers << Logger.new($stderr)
@ -7,19 +7,20 @@ db.create_table(:simples) do
String :status
end
class SequelSimple < Sequel::Model(db)
set_dataset(:simples)
include AASM
module Sequel
class Simple < Sequel::Model(:simples)
include AASM
attr_accessor :default
attr_accessor :default
aasm :column => :status
aasm do
state :alpha, :initial => true
state :beta
state :gamma
event :release do
transitions :from => [:alpha, :beta, :gamma], :to => :beta
aasm :column => :status
aasm do
state :alpha, :initial => true
state :beta
state :gamma
event :release do
transitions :from => [:alpha, :beta, :gamma], :to => :beta
end
end
end
end

View file

@ -0,0 +1,50 @@
db = Sequel::DATABASES.first || Sequel.connect(SEQUEL_DB)
[:silent_persistors, :multiple_silent_persistors].each do |t|
db.create_table(t) do
primary_key :id
String "name"
String "status"
end
end
module Sequel
class SilentPersistor < Sequel::Model(:silent_persistors)
plugin :validation_helpers
include AASM
aasm :column => :status, :whiny_persistence => false do
state :sleeping, :initial => true
state :running
event :run do
transitions :to => :running, :from => :sleeping
end
event :sleep do
transitions :to => :sleeping, :from => :running
end
end
def validate
validates_presence :name
end
end
class MultipleSilentPersistor< Sequel::Model(:multiple_silent_persistors)
plugin :validation_helpers
include AASM
aasm :left, :column => :status, :whiny_persistence => false do
state :sleeping, :initial => true
state :running
event :run do
transitions :to => :running, :from => :sleeping
end
event :sleep do
transitions :to => :sleeping, :from => :running
end
end
def validate
validates_presence :name
end
end
end

View file

@ -0,0 +1,112 @@
db = Sequel::DATABASES.first || Sequel.connect(SEQUEL_DB)
[:transactors, :no_lock_transactors, :lock_transactors, :lock_no_wait_transactors, :multiple_transactors].each do |table_name|
db.create_table(table_name) do
primary_key :id
String "name"
String "status"
Fixnum "worker_id"
end
end
module Sequel
class Transactor < Sequel::Model(:transactors)
many_to_one :worker, class: 'Sequel::Worker'
include AASM
aasm :column => :status do
state :sleeping, :initial => true
state :running, :before_enter => :start_worker, :after_enter => :fail
event :run do
transitions :to => :running, :from => :sleeping
end
end
private
def start_worker
worker.update(status: 'running')
end
def fail
raise StandardError.new('failed on purpose')
end
end
class NoLockTransactor < Sequel::Model(:no_lock_transactors)
many_to_one :worker, class: 'Sequel::Worker'
include AASM
aasm :column => :status do
state :sleeping, :initial => true
state :running
event :run do
transitions :to => :running, :from => :sleeping
end
end
end
class LockTransactor < Sequel::Model(:lock_transactors)
many_to_one :worker, class: 'Sequel::Worker'
include AASM
aasm :column => :status, requires_lock: true do
state :sleeping, :initial => true
state :running
event :run do
transitions :to => :running, :from => :sleeping
end
end
end
class LockNoWaitTransactor < Sequel::Model(:lock_no_wait_transactors)
many_to_one :worker, class: 'Sequel::Worker'
include AASM
aasm :column => :status, requires_lock: 'FOR UPDATE NOWAIT' do
state :sleeping, :initial => true
state :running
event :run do
transitions :to => :running, :from => :sleeping
end
end
end
class MultipleTransactor < Sequel::Model(:multiple_transactors)
many_to_one :worker, class: 'Sequel::Worker'
include AASM
aasm :left, :column => :status do
state :sleeping, :initial => true
state :running, :before_enter => :start_worker, :after_enter => :fail
event :run do
transitions :to => :running, :from => :sleeping
end
end
private
def start_worker
worker.update(:status, 'running')
end
def fail
raise StandardError.new('failed on purpose')
end
end
end

View file

@ -0,0 +1,93 @@
db = Sequel::DATABASES.first || Sequel.connect(SEQUEL_DB)
db.create_table(:validators) do
primary_key :id
String "name"
String "status"
Fixnum "worker_id"
end
module Sequel
class Validator < Sequel::Model(:validators)
plugin :validation_helpers
attr_accessor :after_all_transactions_performed,
:after_transaction_performed_on_fail,
:after_transaction_performed_on_run,
:before_all_transactions_performed,
:before_transaction_performed_on_fail,
:before_transaction_performed_on_run,
:invalid
def validate
super
errors.add(:validator, "invalid") if invalid
validates_presence :name
end
include AASM
aasm :column => :status, :whiny_persistence => true do
before_all_transactions :before_all_transactions
after_all_transactions :after_all_transactions
state :sleeping, :initial => true
state :running
state :failed, :after_enter => :fail
event :run, :after_commit => :change_name! do
after_transaction do
@after_transaction_performed_on_run = true
end
before_transaction do
@before_transaction_performed_on_run = true
end
transitions :to => :running, :from => :sleeping
end
event :sleep do
after_commit do |name|
change_name_on_sleep name
end
transitions :to => :sleeping, :from => :running
end
event :fail do
after_transaction do
@after_transaction_performed_on_fail = true
end
before_transaction do
@before_transaction_performed_on_fail = true
end
transitions :to => :failed, :from => [:sleeping, :running]
end
end
def change_name!
self.name = "name changed"
save(raise_on_failure: true)
end
def change_name_on_sleep name
self.name = name
save(raise_on_failure: true)
end
def fail
raise StandardError.new('failed on purpose')
end
def after_all_transactions
@after_all_transactions_performed = true
end
def before_all_transactions
@before_all_transactions_performed = true
end
end
end

View file

@ -0,0 +1,12 @@
db = Sequel::DATABASES.first || Sequel.connect(SEQUEL_DB)
db.create_table(:workers) do
primary_key :id
String "name"
String "status"
end
module Sequel
class Worker < Sequel::Model(:workers)
end
end

View file

@ -8,7 +8,7 @@ if defined?(Sequel)
end
before(:all) do
@model = SequelMultiple
@model = Sequel::Multiple
end
describe "instance methods" do
@ -93,7 +93,7 @@ if defined?(Sequel)
describe "complex example" do
it "works" do
record = ComplexSequelExample.new
record = Sequel::ComplexExample.new
expect(record.aasm(:left).current_state).to eql :one
expect(record.left).to be_nil
expect(record.aasm(:right).current_state).to eql :alpha

View file

@ -1,14 +1,13 @@
require 'spec_helper'
if defined?(Sequel)
describe 'sequel' do
Dir[File.dirname(__FILE__) + "/../../models/sequel/*.rb"].sort.each do |f|
require File.expand_path(f)
end
before(:all) do
@model = SequelSimple
@model = Sequel::Simple
end
describe "instance methods" do
@ -91,5 +90,279 @@ if defined?(Sequel)
end
end
describe 'transitions with persistence' do
it "should work for valid models" do
valid_object = Sequel::Validator.create(:name => 'name')
expect(valid_object).to be_sleeping
valid_object.status = :running
expect(valid_object).to be_running
end
it 'should not store states for invalid models' do
validator = Sequel::Validator.create(:name => 'name')
expect(validator).to be_valid
expect(validator).to be_sleeping
validator.name = nil
expect(validator).not_to be_valid
expect { validator.run! }.to raise_error(Sequel::ValidationFailed)
expect(validator).to be_sleeping
validator.reload
expect(validator).not_to be_running
expect(validator).to be_sleeping
validator.name = 'another name'
expect(validator).to be_valid
expect(validator.run!).to be_truthy
expect(validator).to be_running
validator.reload
expect(validator).to be_running
expect(validator).not_to be_sleeping
end
it 'should not store states for invalid models silently if configured' do
validator = Sequel::SilentPersistor.create(:name => 'name')
expect(validator).to be_valid
expect(validator).to be_sleeping
validator.name = nil
expect(validator).not_to be_valid
expect(validator.run!).to be_falsey
expect(validator).to be_sleeping
validator.reload
expect(validator).not_to be_running
expect(validator).to be_sleeping
validator.name = 'another name'
expect(validator).to be_valid
expect(validator.run!).to be_truthy
expect(validator).to be_running
validator.reload
expect(validator).to be_running
expect(validator).not_to be_sleeping
end
it 'should store states for invalid models if configured' do
persistor = Sequel::InvalidPersistor.create(:name => 'name')
expect(persistor).to be_valid
expect(persistor).to be_sleeping
persistor.name = nil
expect(persistor).not_to be_valid
expect(persistor.run!).to be_truthy
expect(persistor).to be_running
persistor = Sequel::InvalidPersistor[persistor.id]
persistor.valid?
expect(persistor).to be_valid
expect(persistor).to be_running
expect(persistor).not_to be_sleeping
persistor.reload
expect(persistor).to be_running
expect(persistor).not_to be_sleeping
end
describe 'pessimistic locking' do
let(:worker) { Sequel::Worker.create(:name => 'worker', :status => 'sleeping') }
subject { transactor.run! }
context 'no lock' do
let(:transactor) { Sequel::NoLockTransactor.create(:name => 'no_lock_transactor', :worker => worker) }
it 'should not invoke lock!' do
expect(transactor).to_not receive(:lock!)
subject
end
end
context 'a default lock' do
let(:transactor) { Sequel::LockTransactor.create(:name => 'lock_transactor', :worker => worker) }
it 'should invoke lock!' do
expect(transactor).to receive(:lock!).and_call_original
subject
end
end
context 'a FOR UPDATE NOWAIT lock' do
let(:transactor) { Sequel::LockNoWaitTransactor.create(:name => 'lock_no_wait_transactor', :worker => worker) }
it 'should invoke lock! with FOR UPDATE NOWAIT' do
# TODO: With and_call_original, get an error with syntax, should look into it.
expect(transactor).to receive(:lock!).with('FOR UPDATE NOWAIT')# .and_call_original
subject
end
end
end
describe 'transactions' do
let(:worker) { Sequel::Worker.create(:name => 'worker', :status => 'sleeping') }
let(:transactor) { Sequel::Transactor.create(:name => 'transactor', :worker => worker) }
it 'should rollback all changes' do
expect(transactor).to be_sleeping
expect(worker.status).to eq('sleeping')
expect {transactor.run!}.to raise_error(StandardError, 'failed on purpose')
expect(transactor).to be_running
expect(worker.reload.status).to eq('sleeping')
end
context "nested transactions" do
it "should rollback all changes in nested transaction" do
expect(transactor).to be_sleeping
expect(worker.status).to eq('sleeping')
Sequel::Worker.db.transaction do
expect { transactor.run! }.to raise_error(StandardError, 'failed on purpose')
end
expect(transactor).to be_running
expect(worker.reload.status).to eq('sleeping')
end
it "should only rollback changes in the main transaction not the nested one" do
# change configuration to not require new transaction
AASM::StateMachineStore[Sequel::Transactor][:default].config.requires_new_transaction = false
expect(transactor).to be_sleeping
expect(worker.status).to eq('sleeping')
Sequel::Worker.db.transaction do
expect { transactor.run! }.to raise_error(StandardError, 'failed on purpose')
end
expect(transactor).to be_running
expect(worker.reload.status).to eq('running')
end
end
describe "after_commit callback" do
it "should fire :after_commit if transaction was successful" do
validator = Sequel::Validator.create(:name => 'name')
expect(validator).to be_sleeping
validator.run!
expect(validator).to be_running
expect(validator.name).to eq("name changed")
validator.sleep!("sleeper")
expect(validator).to be_sleeping
expect(validator.name).to eq("sleeper")
end
it "should not fire :after_commit if transaction failed" do
validator = Sequel::Validator.create(:name => 'name')
expect { validator.fail! }.to raise_error(StandardError, 'failed on purpose')
expect(validator.name).to eq("name")
end
it "should not fire :after_commit if validation failed when saving object" do
validator = Sequel::Validator.create(:name => 'name')
validator.invalid = true
expect { validator.run! }.to raise_error(Sequel::ValidationFailed, 'validator invalid')
expect(validator).to be_sleeping
expect(validator.name).to eq("name")
end
it "should not fire if not saving" do
validator = Sequel::Validator.create(:name => 'name')
expect(validator).to be_sleeping
validator.run
expect(validator).to be_running
expect(validator.name).to eq("name")
end
end
describe 'before and after transaction callbacks' do
[:after, :before].each do |event_type|
describe "#{event_type}_transaction callback" do
it "should fire :#{event_type}_transaction if transaction was successful" do
validator = Sequel::Validator.create(:name => 'name')
expect(validator).to be_sleeping
expect { validator.run! }.to change { validator.send("#{event_type}_transaction_performed_on_run") }.from(nil).to(true)
expect(validator).to be_running
end
it "should fire :#{event_type}_transaction if transaction failed" do
validator = Sequel::Validator.create(:name => 'name')
expect do
begin
validator.fail!
rescue => ignored
end
end.to change { validator.send("#{event_type}_transaction_performed_on_fail") }.from(nil).to(true)
expect(validator).to_not be_running
end
it "should not fire :#{event_type}_transaction if not saving" do
validator = Sequel::Validator.create(:name => 'name')
expect(validator).to be_sleeping
expect { validator.run }.to_not change { validator.send("#{event_type}_transaction_performed_on_run") }
expect(validator).to be_running
expect(validator.name).to eq("name")
end
end
end
end
describe 'before and after all transactions callbacks' do
[:after, :before].each do |event_type|
describe "#{event_type}_all_transactions callback" do
it "should fire :#{event_type}_all_transactions if transaction was successful" do
validator = Sequel::Validator.create(:name => 'name')
expect(validator).to be_sleeping
expect { validator.run! }.to change { validator.send("#{event_type}_all_transactions_performed") }.from(nil).to(true)
expect(validator).to be_running
end
it "should fire :#{event_type}_all_transactions if transaction failed" do
validator = Sequel::Validator.create(:name => 'name')
expect do
begin
validator.fail!
rescue => ignored
end
end.to change { validator.send("#{event_type}_all_transactions_performed") }.from(nil).to(true)
expect(validator).to_not be_running
end
it "should not fire :#{event_type}_all_transactions if not saving" do
validator = Sequel::Validator.create(:name => 'name')
expect(validator).to be_sleeping
expect { validator.run }.to_not change { validator.send("#{event_type}_all_transactions_performed") }
expect(validator).to be_running
expect(validator.name).to eq("name")
end
end
end
end
context "when not persisting" do
it 'should not rollback all changes' do
expect(transactor).to be_sleeping
expect(worker.status).to eq('sleeping')
# Notice here we're calling "run" and not "run!" with a bang.
expect {transactor.run}.to raise_error(StandardError, 'failed on purpose')
expect(transactor).to be_running
expect(worker.reload.status).to eq('running')
end
it 'should not create a database transaction' do
expect(transactor.class).not_to receive(:transaction)
expect {transactor.run}.to raise_error(StandardError, 'failed on purpose')
end
end
end
end
end
end