From fbf9281f0e3ced714bc534821c8b241ed7ec358e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 31 Dec 2004 19:38:04 +0000 Subject: Added automated optimistic locking if the field lock_version is present #384 [Michael Koziarski] git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@295 5ecf4fe2-1ee6-0310-87b1-e25e094e27de --- activerecord/CHANGELOG | 18 +++++++ activerecord/lib/active_record.rb | 6 ++- activerecord/lib/active_record/base.rb | 2 + activerecord/lib/active_record/locking.rb | 57 ++++++++++++++++++++++ .../test/fixtures/db_definitions/mysql.sql | 6 +++ .../test/fixtures/db_definitions/postgresql.sql | 7 +++ .../test/fixtures/db_definitions/sqlite.sql | 6 ++- .../test/fixtures/db_definitions/sqlserver.sql | 6 +++ activerecord/test/fixtures/people.yml | 3 ++ activerecord/test/fixtures/person.rb | 1 + activerecord/test/locking_test.rb | 34 +++++++++++++ 11 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 activerecord/lib/active_record/locking.rb create mode 100644 activerecord/test/fixtures/people.yml create mode 100644 activerecord/test/fixtures/person.rb create mode 100644 activerecord/test/locking_test.rb (limited to 'activerecord') diff --git a/activerecord/CHANGELOG b/activerecord/CHANGELOG index b97085cdba..8a0d75ee8a 100644 --- a/activerecord/CHANGELOG +++ b/activerecord/CHANGELOG @@ -1,5 +1,23 @@ *SVN* +* Added automated optimistic locking if the field lock_version is present. Each update to the + record increments the lock_version column and the locking facilities ensure that records instantiated twice + will let the last one saved raise a StaleObjectError if the first was also updated. Example: + + p1 = Person.find(1) + p2 = Person.find(1) + + p1.first_name = "Michael" + p1.save + + p2.first_name = "should fail" + p2.save # Raises a ActiveRecord::StaleObjectError + + You're then responsible for dealing with the conflict by rescuing the exception and either rolling back, merging, + or otherwise apply the business logic needed to resolve the conflict. + + #384 [Michael Koziarski] + * Added :counter_cache option to acts_as_tree that works just like the one you can define on belongs_to #371 [Josh] * Fixed quoting in validates_format_of that would allow some rules to pass regardless of input #390 [Dmitry V. Sabanin] diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index ecf8499e16..963a14dd3e 100755 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -39,11 +39,13 @@ require 'active_record/reflection' require 'active_record/timestamp' require 'active_record/acts/list' require 'active_record/acts/tree' +require 'active_record/locking' ActiveRecord::Base.class_eval do include ActiveRecord::Validations - include ActiveRecord::Timestamp include ActiveRecord::Callbacks + include ActiveRecord::Locking + include ActiveRecord::Timestamp include ActiveRecord::Associations include ActiveRecord::Aggregations include ActiveRecord::Transactions @@ -55,4 +57,4 @@ end require 'active_record/connection_adapters/mysql_adapter' require 'active_record/connection_adapters/postgresql_adapter' require 'active_record/connection_adapters/sqlite_adapter' -require 'active_record/connection_adapters/sqlserver_adapter' \ No newline at end of file +require 'active_record/connection_adapters/sqlserver_adapter' diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index e4283ac157..849fb1578b 100755 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -26,6 +26,8 @@ module ActiveRecord #:nodoc: end class PreparedStatementInvalid < ActiveRecordError #:nodoc: end + class StaleObjectError < ActiveRecordError #:nodoc: + end # Active Record objects doesn't specify their attributes directly, but rather infer them from the table definition with # which they're linked. Adding, removing, and changing attributes and their type is done directly in the database. Any change diff --git a/activerecord/lib/active_record/locking.rb b/activerecord/lib/active_record/locking.rb new file mode 100644 index 0000000000..e08e2fcec8 --- /dev/null +++ b/activerecord/lib/active_record/locking.rb @@ -0,0 +1,57 @@ +module ActiveRecord + module Locking + # Active Records support optimistic locking if the field lock_version is present. Each update to the + # record increments the lock_version column and the locking facilities ensure that records instantiated twice + # will let the last one saved raise a StaleObjectError if the first was also updated. Example: + # + # p1 = Person.find(1) + # p2 = Person.find(1) + # + # p1.first_name = "Michael" + # p1.save + # + # p2.first_name = "should fail" + # p2.save # Raises a ActiveRecord::StaleObjectError + # + # You're then responsible for dealing with the conflict by rescuing the exception and either rolling back, merging, + # or otherwise apply the business logic needed to resolve the conflict. + # + # You must ensure that your database schema defaults the lock_version column to 0. + # + # This behavior can be turned off by setting ActiveRecord::Base.lock_optimistically = false. + def self.append_features(base) + super + base.class_eval do + alias_method :update_without_lock, :update + alias_method :update, :update_with_lock + end + end + + def update_with_lock + if locking_enabled? + previous_value = self.lock_version + self.lock_version = previous_value + 1 + + affected_rows = connection.update( + "UPDATE #{self.class.table_name} "+ + "SET #{quoted_comma_pair_list(connection, attributes_with_quotes(false))} " + + "WHERE #{self.class.primary_key} = #{quote(id)} AND lock_version = #{quote(previous_value)}", + "#{self.class.name} Update with optimistic locking" + ) + + raise(ActiveRecord::StaleObjectError, "Attempted to update a stale object") unless affected_rows == 1 + else + update_without_lock + end + end + end + + class Base + @@lock_optimistically = true + cattr_accessor :lock_optimistically + + def locking_enabled? + lock_optimistically && respond_to?(:lock_version) + end + end +end \ No newline at end of file diff --git a/activerecord/test/fixtures/db_definitions/mysql.sql b/activerecord/test/fixtures/db_definitions/mysql.sql index 7bd06e6243..ec27a52445 100755 --- a/activerecord/test/fixtures/db_definitions/mysql.sql +++ b/activerecord/test/fixtures/db_definitions/mysql.sql @@ -110,3 +110,9 @@ CREATE TABLE `mixins` ( `type` varchar(40) default NULL, PRIMARY KEY (`id`) ); + +CREATE TABLE `people` ( + `id` INTEGER NOT NULL PRIMARY KEY, + `first_name` VARCHAR(40) NOT NULL, + `lock_version` INTEGER NOT NULL DEFAULT 0 +); \ No newline at end of file diff --git a/activerecord/test/fixtures/db_definitions/postgresql.sql b/activerecord/test/fixtures/db_definitions/postgresql.sql index 305852ba7f..6d8222cfd6 100644 --- a/activerecord/test/fixtures/db_definitions/postgresql.sql +++ b/activerecord/test/fixtures/db_definitions/postgresql.sql @@ -127,3 +127,10 @@ CREATE TABLE mixins ( updated_at timestamp, PRIMARY KEY (id) ); + +CREATE TABLE people ( + id serial, + first_name text, + lock_version integer default 0, + PRIMARY KEY (id) +); \ No newline at end of file diff --git a/activerecord/test/fixtures/db_definitions/sqlite.sql b/activerecord/test/fixtures/db_definitions/sqlite.sql index 9bab9d9a15..5c27832056 100644 --- a/activerecord/test/fixtures/db_definitions/sqlite.sql +++ b/activerecord/test/fixtures/db_definitions/sqlite.sql @@ -99,4 +99,8 @@ CREATE TABLE 'mixins' ( 'updated_at' DATETIME DEFAULT NULL ); - +CREATE TABLE 'people' ( + 'id' INTEGER NOT NULL PRIMARY KEY, + 'first_name' VARCHAR(40) DEFAULT NULL, + 'lock_version' INTEGER NOT NULL DEFAULT 0 +); \ No newline at end of file diff --git a/activerecord/test/fixtures/db_definitions/sqlserver.sql b/activerecord/test/fixtures/db_definitions/sqlserver.sql index 4152ef59cb..74cd381ba3 100644 --- a/activerecord/test/fixtures/db_definitions/sqlserver.sql +++ b/activerecord/test/fixtures/db_definitions/sqlserver.sql @@ -110,3 +110,9 @@ CREATE TABLE mixins ( ); +CREATE TABLE people ( + id int NOT NULL IDENTITY(1, 1), + first_name varchar(40) NULL, + lock_version int default 0, + PRIMARY KEY (id) +); \ No newline at end of file diff --git a/activerecord/test/fixtures/people.yml b/activerecord/test/fixtures/people.yml new file mode 100644 index 0000000000..22c64afb70 --- /dev/null +++ b/activerecord/test/fixtures/people.yml @@ -0,0 +1,3 @@ +michael: + id: 1 + first_name: Michael \ No newline at end of file diff --git a/activerecord/test/fixtures/person.rb b/activerecord/test/fixtures/person.rb new file mode 100644 index 0000000000..4fa5811c71 --- /dev/null +++ b/activerecord/test/fixtures/person.rb @@ -0,0 +1 @@ +class Person < ActiveRecord::Base; end diff --git a/activerecord/test/locking_test.rb b/activerecord/test/locking_test.rb new file mode 100644 index 0000000000..2b77a29907 --- /dev/null +++ b/activerecord/test/locking_test.rb @@ -0,0 +1,34 @@ +require 'abstract_unit' +require 'fixtures/person' + +class LockingTest < Test::Unit::TestCase + def setup + @people = create_fixtures('people') + end + + def test_lock_existing + p1 = Person.find(1) + p2 = Person.find(1) + + p1.first_name = "Michael" + p1.save + + assert_raises(ActiveRecord::StaleObjectError) { + p2.first_name = "should fail" + p2.save + } + end + + def test_lock_new + p1 = Person.create({ "first_name"=>"anika"}) + p2 = Person.find(p1.id) + assert_equal p1.id, p2.id + p1.first_name = "Anika" + p1.save + + assert_raises(ActiveRecord::StaleObjectError) { + p2.first_name = "should fail" + p2.save + } + end +end \ No newline at end of file -- cgit v1.2.3