Given the following models,
class User < ActiveRecord::Base
has_many :courses
end
class Course < ActiveRecord::Base
belongs_to :user
end
where Course has a lock version column defined,
create_table :courses do |t|
...
t.integer :lock_version, :default => 0
...
end
the following unit test to force a locking collision will
pass,
test "optimistic_locking_behaviour" do
first_course_instance = Course.find_by_name('Rails 101')
second_course_instance = Course.find_by_name('Rails 101')
first_course_instance.user = users(:one)
second_course_instance.user = users(:two)
assert first_course_instance.save, "First instance save succeeded"
assert_raises ActiveRecord::StaleObjectError do
second_course_instance.save
end
end
a look at log/test.log shows,
UPDATE `courses` SET `user_id` = 980190962, `updated_at` = '2011-01-26 13:02:53', `lock_version` = 1 WHERE (`courses`.`id` = 52995405 AND `courses`.`lock_version` = 0)
where the resulting query on the course object correctly checks
the lock_version on update.
However, if the relationship between User and Course is changed
to be many to many using has_many :through, there is no explicit
checking of the lock_version on update,
class User < ActiveRecord::Base
has_many :attendances
has_many :registered_courses, :through => :attendances, :source => :course
end
class Course < ActiveRecord::Base
has_many :attendances
has_many :attendees, :through => :attendances, :source => :user
end
class Attendance < ActiveRecord::Base
belongs_to :user
belongs_to :course, :counter_cache => true, :touch => true
end
and with a unit test to force a lock collision as before,
test "optimistic_locking_behaviour" do
first_course_instance = Course.find_by_name('Rails 101')
second_course_instance = Course.find_by_name('Rails 101')
first_course_instance.touch
assert first_course_instance.attendees << users(:one), "First instance succeeded"
assert_raises ActiveRecord::StaleObjectError do
second_course_instance.attendees << users(:two)
end
end
results in the following set of sql output,
SQL (0.0ms) BEGIN
SQL (0.4ms) SHOW TABLES
Course Load (0.3ms) SELECT `courses`.* FROM `courses` WHERE (`courses`.`name` = 'Rails 101') LIMIT 1
Course Load (0.1ms) SELECT `courses`.* FROM `courses` WHERE (`courses`.`name` = 'Rails 101') LIMIT 1
User Load (0.4ms) SELECT `users`.* FROM `users` WHERE (`users`.`id` = 980190962) LIMIT 1
SQL (0.1ms) SAVEPOINT active_record_1
SQL (2.8ms) describe `attendances`
AREL (0.2ms) INSERT INTO `attendances` (`user_id`, `course_id`, `created_at`, `updated_at`) VALUES (980190962, 52995405, '2011-01-26 13:24:48', '2011-01-26 13:24:48')
Course Load (0.3ms) SELECT `courses`.* FROM `courses` WHERE (`courses`.`id` = 52995405) LIMIT 1
AREL (0.2ms) UPDATE `courses` SET `attendances_count` = COALESCE(`attendances_count`, 0) + 1, `lock_version` = COALESCE(`lock_version`, 0) + 1 WHERE (`courses`.`id` = 52995405)
**AREL (0.1ms) UPDATE `courses` SET `updated_at` = '2011-01-26 13:24:48' WHERE (`courses`.`id` = 52995405)**
SQL (0.1ms) RELEASE SAVEPOINT active_record_1
User Load (0.1ms) SELECT `users`.* FROM `users` WHERE (`users`.`id` = 298486374) LIMIT 1
SQL (0.1ms) SAVEPOINT active_record_1
AREL (0.2ms) INSERT INTO `attendances` (`user_id`, `course_id`, `created_at`, `updated_at`) VALUES (298486374, 52995405, '2011-01-26 13:24:48', '2011-01-26 13:24:48')
Course Load (0.1ms) SELECT `courses`.* FROM `courses` WHERE (`courses`.`id` = 52995405) LIMIT 1
AREL (0.1ms) UPDATE `courses` SET `attendances_count` = COALESCE(`attendances_count`, 0) + 1, `lock_version` = COALESCE(`lock_version`, 0) + 1 WHERE (`courses`.`id` = 52995405)
AREL (0.1ms) UPDATE `courses` SET `updated_at` = '2011-01-26 13:24:48' WHERE (`courses`.`id` = 52995405)
SQL (0.1ms) RELEASE SAVEPOINT active_record_1
SQL (1.3ms) ROLLBACK
Notice that the implicit update to the Course object
(highlighted in bold), facilitated through the
:touch => true
option, doesn't take into account the lock_version of the row.
Therefore, a locking collision will never be detected in the test.
I think that either
a) ActiveRecord.touch should be aware of the lock_version column
and handle this accordingly
or
b) the resulting update to the parent object (Course) should be
check for a lock_version mismatch rather than simply increasing the
lock version value by 1 i.e.
AREL (0.2ms) UPDATE `courses` SET `attendances_count` = COALESCE(`attendances_count`, 0) + 1, `lock_version` = COALESCE(`lock_version`, 0) + 1 WHERE (`courses`.`id` = 52995405)