mirror of
https://github.com/rails/rails.git
synced 2022-11-09 12:12:34 -05:00
13772bfa49
The reason this bug occured is that we never actually check to see if
this column has changed from it's default, since it was never assigned
and is not mutable.
It appears I was wrong in b301c40224
, with
my statement of "there is no longer a case where a given value would
differ from the default, but would not already be marked as changed."
However, I chose not to revert the deletion of
`initialize_internals_callback` from that commit, as I think a solution
closer to where the problem lies is less likely to get erroneously
removed. I'm not super happy with this solution, but it mirrors what is
being done in `_update_record`, and a fix for one should work for the
other.
I toyed with the idea of changing the definition of `changed?` on the
type to `changed_in_place?`. If we type cast the raw value, it'll break
a test about updating not modifying the lock column if nothing else was
changed. We could have the definition check if `raw_old_value` is `nil`,
but this feels fragile and less intention revealing. It would, however,
have the benefit of cleaning up old data that incorrectly persisted as
`nil`.
Fixes #18422
477 lines
13 KiB
Ruby
477 lines
13 KiB
Ruby
require 'thread'
|
|
require "cases/helper"
|
|
require 'models/person'
|
|
require 'models/job'
|
|
require 'models/reader'
|
|
require 'models/ship'
|
|
require 'models/legacy_thing'
|
|
require 'models/personal_legacy_thing'
|
|
require 'models/reference'
|
|
require 'models/string_key_object'
|
|
require 'models/car'
|
|
require 'models/bulb'
|
|
require 'models/engine'
|
|
require 'models/wheel'
|
|
require 'models/treasure'
|
|
|
|
class LockWithoutDefault < ActiveRecord::Base; end
|
|
|
|
class LockWithCustomColumnWithoutDefault < ActiveRecord::Base
|
|
self.table_name = :lock_without_defaults_cust
|
|
self.column_defaults # to test @column_defaults caching.
|
|
self.locking_column = :custom_lock_version
|
|
end
|
|
|
|
class ReadonlyNameShip < Ship
|
|
attr_readonly :name
|
|
end
|
|
|
|
class OptimisticLockingTest < ActiveRecord::TestCase
|
|
fixtures :people, :legacy_things, :references, :string_key_objects, :peoples_treasures
|
|
|
|
def test_quote_value_passed_lock_col
|
|
p1 = Person.find(1)
|
|
assert_equal 0, p1.lock_version
|
|
|
|
p1.first_name = 'anika2'
|
|
p1.save!
|
|
|
|
assert_equal 1, p1.lock_version
|
|
end
|
|
|
|
def test_non_integer_lock_existing
|
|
s1 = StringKeyObject.find("record1")
|
|
s2 = StringKeyObject.find("record1")
|
|
assert_equal 0, s1.lock_version
|
|
assert_equal 0, s2.lock_version
|
|
|
|
s1.name = 'updated record'
|
|
s1.save!
|
|
assert_equal 1, s1.lock_version
|
|
assert_equal 0, s2.lock_version
|
|
|
|
s2.name = 'doubly updated record'
|
|
assert_raise(ActiveRecord::StaleObjectError) { s2.save! }
|
|
end
|
|
|
|
def test_non_integer_lock_destroy
|
|
s1 = StringKeyObject.find("record1")
|
|
s2 = StringKeyObject.find("record1")
|
|
assert_equal 0, s1.lock_version
|
|
assert_equal 0, s2.lock_version
|
|
|
|
s1.name = 'updated record'
|
|
s1.save!
|
|
assert_equal 1, s1.lock_version
|
|
assert_equal 0, s2.lock_version
|
|
assert_raise(ActiveRecord::StaleObjectError) { s2.destroy }
|
|
|
|
assert s1.destroy
|
|
assert s1.frozen?
|
|
assert s1.destroyed?
|
|
assert_raises(ActiveRecord::RecordNotFound) { StringKeyObject.find("record1") }
|
|
end
|
|
|
|
def test_lock_existing
|
|
p1 = Person.find(1)
|
|
p2 = Person.find(1)
|
|
assert_equal 0, p1.lock_version
|
|
assert_equal 0, p2.lock_version
|
|
|
|
p1.first_name = 'stu'
|
|
p1.save!
|
|
assert_equal 1, p1.lock_version
|
|
assert_equal 0, p2.lock_version
|
|
|
|
p2.first_name = 'sue'
|
|
assert_raise(ActiveRecord::StaleObjectError) { p2.save! }
|
|
end
|
|
|
|
# See Lighthouse ticket #1966
|
|
def test_lock_destroy
|
|
p1 = Person.find(1)
|
|
p2 = Person.find(1)
|
|
assert_equal 0, p1.lock_version
|
|
assert_equal 0, p2.lock_version
|
|
|
|
p1.first_name = 'stu'
|
|
p1.save!
|
|
assert_equal 1, p1.lock_version
|
|
assert_equal 0, p2.lock_version
|
|
|
|
assert_raises(ActiveRecord::StaleObjectError) { p2.destroy }
|
|
|
|
assert p1.destroy
|
|
assert p1.frozen?
|
|
assert p1.destroyed?
|
|
assert_raises(ActiveRecord::RecordNotFound) { Person.find(1) }
|
|
end
|
|
|
|
def test_lock_repeating
|
|
p1 = Person.find(1)
|
|
p2 = Person.find(1)
|
|
assert_equal 0, p1.lock_version
|
|
assert_equal 0, p2.lock_version
|
|
|
|
p1.first_name = 'stu'
|
|
p1.save!
|
|
assert_equal 1, p1.lock_version
|
|
assert_equal 0, p2.lock_version
|
|
|
|
p2.first_name = 'sue'
|
|
assert_raise(ActiveRecord::StaleObjectError) { p2.save! }
|
|
p2.first_name = 'sue2'
|
|
assert_raise(ActiveRecord::StaleObjectError) { p2.save! }
|
|
end
|
|
|
|
def test_lock_new
|
|
p1 = Person.new(:first_name => 'anika')
|
|
assert_equal 0, p1.lock_version
|
|
|
|
p1.first_name = 'anika2'
|
|
p1.save!
|
|
p2 = Person.find(p1.id)
|
|
assert_equal 0, p1.lock_version
|
|
assert_equal 0, p2.lock_version
|
|
|
|
p1.first_name = 'anika3'
|
|
p1.save!
|
|
assert_equal 1, p1.lock_version
|
|
assert_equal 0, p2.lock_version
|
|
|
|
p2.first_name = 'sue'
|
|
assert_raise(ActiveRecord::StaleObjectError) { p2.save! }
|
|
end
|
|
|
|
def test_lock_exception_record
|
|
p1 = Person.new(:first_name => 'mira')
|
|
assert_equal 0, p1.lock_version
|
|
|
|
p1.first_name = 'mira2'
|
|
p1.save!
|
|
p2 = Person.find(p1.id)
|
|
assert_equal 0, p1.lock_version
|
|
assert_equal 0, p2.lock_version
|
|
|
|
p1.first_name = 'mira3'
|
|
p1.save!
|
|
|
|
p2.first_name = 'sue'
|
|
error = assert_raise(ActiveRecord::StaleObjectError) { p2.save! }
|
|
assert_equal(error.record.object_id, p2.object_id)
|
|
end
|
|
|
|
def test_lock_new_with_nil
|
|
p1 = Person.new(:first_name => 'anika')
|
|
p1.save!
|
|
p1.lock_version = nil # simulate bad fixture or column with no default
|
|
p1.save!
|
|
assert_equal 1, p1.lock_version
|
|
end
|
|
|
|
def test_touch_existing_lock
|
|
p1 = Person.find(1)
|
|
assert_equal 0, p1.lock_version
|
|
|
|
p1.touch
|
|
assert_equal 1, p1.lock_version
|
|
end
|
|
|
|
def test_lock_column_name_existing
|
|
t1 = LegacyThing.find(1)
|
|
t2 = LegacyThing.find(1)
|
|
assert_equal 0, t1.version
|
|
assert_equal 0, t2.version
|
|
|
|
t1.tps_report_number = 700
|
|
t1.save!
|
|
assert_equal 1, t1.version
|
|
assert_equal 0, t2.version
|
|
|
|
t2.tps_report_number = 800
|
|
assert_raise(ActiveRecord::StaleObjectError) { t2.save! }
|
|
end
|
|
|
|
def test_lock_column_is_mass_assignable
|
|
p1 = Person.create(:first_name => 'bianca')
|
|
assert_equal 0, p1.lock_version
|
|
assert_equal p1.lock_version, Person.new(p1.attributes).lock_version
|
|
|
|
p1.first_name = 'bianca2'
|
|
p1.save!
|
|
assert_equal 1, p1.lock_version
|
|
assert_equal p1.lock_version, Person.new(p1.attributes).lock_version
|
|
end
|
|
|
|
def test_lock_without_default_sets_version_to_zero
|
|
t1 = LockWithoutDefault.new
|
|
assert_equal 0, t1.lock_version
|
|
|
|
t1.save
|
|
t1 = LockWithoutDefault.find(t1.id)
|
|
assert_equal 0, t1.lock_version
|
|
end
|
|
|
|
def test_lock_with_custom_column_without_default_sets_version_to_zero
|
|
t1 = LockWithCustomColumnWithoutDefault.new
|
|
assert_equal 0, t1.custom_lock_version
|
|
assert_nil t1.custom_lock_version_before_type_cast
|
|
|
|
t1.save!
|
|
t1.reload
|
|
assert_equal 0, t1.custom_lock_version
|
|
assert [0, "0"].include?(t1.custom_lock_version_before_type_cast)
|
|
end
|
|
|
|
def test_readonly_attributes
|
|
assert_equal Set.new([ 'name' ]), ReadonlyNameShip.readonly_attributes
|
|
|
|
s = ReadonlyNameShip.create(:name => "unchangeable name")
|
|
s.reload
|
|
assert_equal "unchangeable name", s.name
|
|
|
|
s.update(name: "changed name")
|
|
s.reload
|
|
assert_equal "unchangeable name", s.name
|
|
end
|
|
|
|
def test_quote_table_name
|
|
ref = references(:michael_magician)
|
|
ref.favourite = !ref.favourite
|
|
assert ref.save
|
|
end
|
|
|
|
# Useful for partial updates, don't only update the lock_version if there
|
|
# is nothing else being updated.
|
|
def test_update_without_attributes_does_not_only_update_lock_version
|
|
assert_nothing_raised do
|
|
p1 = Person.create!(:first_name => 'anika')
|
|
lock_version = p1.lock_version
|
|
p1.save
|
|
p1.reload
|
|
assert_equal lock_version, p1.lock_version
|
|
end
|
|
end
|
|
|
|
def test_polymorphic_destroy_with_dependencies_and_lock_version
|
|
car = Car.create!
|
|
|
|
assert_difference 'car.wheels.count' do
|
|
car.wheels << Wheel.create!
|
|
end
|
|
assert_difference 'car.wheels.count', -1 do
|
|
car.destroy
|
|
end
|
|
assert car.destroyed?
|
|
end
|
|
|
|
def test_removing_has_and_belongs_to_many_associations_upon_destroy
|
|
p = RichPerson.create! first_name: 'Jon'
|
|
p.treasures.create!
|
|
assert !p.treasures.empty?
|
|
p.destroy
|
|
assert p.treasures.empty?
|
|
assert RichPerson.connection.select_all("SELECT * FROM peoples_treasures WHERE rich_person_id = 1").empty?
|
|
end
|
|
|
|
def test_yaml_dumping_with_lock_column
|
|
t1 = LockWithoutDefault.new
|
|
t2 = YAML.load(YAML.dump(t1))
|
|
|
|
assert_equal t1.attributes, t2.attributes
|
|
end
|
|
end
|
|
|
|
class OptimisticLockingWithSchemaChangeTest < ActiveRecord::TestCase
|
|
fixtures :people, :legacy_things, :references
|
|
|
|
# need to disable transactional fixtures, because otherwise the sqlite3
|
|
# adapter (at least) chokes when we try and change the schema in the middle
|
|
# of a test (see test_increment_counter_*).
|
|
self.use_transactional_fixtures = false
|
|
|
|
{ :lock_version => Person, :custom_lock_version => LegacyThing }.each do |name, model|
|
|
define_method("test_increment_counter_updates_#{name}") do
|
|
counter_test model, 1 do |id|
|
|
model.increment_counter :test_count, id
|
|
end
|
|
end
|
|
|
|
define_method("test_decrement_counter_updates_#{name}") do
|
|
counter_test model, -1 do |id|
|
|
model.decrement_counter :test_count, id
|
|
end
|
|
end
|
|
|
|
define_method("test_update_counters_updates_#{name}") do
|
|
counter_test model, 1 do |id|
|
|
model.update_counters id, :test_count => 1
|
|
end
|
|
end
|
|
end
|
|
|
|
# See Lighthouse ticket #1966
|
|
def test_destroy_dependents
|
|
# Establish dependent relationship between Person and PersonalLegacyThing
|
|
add_counter_column_to(Person, 'personal_legacy_things_count')
|
|
PersonalLegacyThing.reset_column_information
|
|
|
|
# Make sure that counter incrementing doesn't cause problems
|
|
p1 = Person.new(:first_name => 'fjord')
|
|
p1.save!
|
|
t = PersonalLegacyThing.new(:person => p1)
|
|
t.save!
|
|
p1.reload
|
|
assert_equal 1, p1.personal_legacy_things_count
|
|
assert p1.destroy
|
|
assert_equal true, p1.frozen?
|
|
assert_raises(ActiveRecord::RecordNotFound) { Person.find(p1.id) }
|
|
assert_raises(ActiveRecord::RecordNotFound) { PersonalLegacyThing.find(t.id) }
|
|
ensure
|
|
remove_counter_column_from(Person, 'personal_legacy_things_count')
|
|
PersonalLegacyThing.reset_column_information
|
|
end
|
|
|
|
private
|
|
|
|
def add_counter_column_to(model, col='test_count')
|
|
model.connection.add_column model.table_name, col, :integer, :null => false, :default => 0
|
|
model.reset_column_information
|
|
end
|
|
|
|
def remove_counter_column_from(model, col = :test_count)
|
|
model.connection.remove_column model.table_name, col
|
|
model.reset_column_information
|
|
end
|
|
|
|
def counter_test(model, expected_count)
|
|
add_counter_column_to(model)
|
|
object = model.first
|
|
assert_equal 0, object.test_count
|
|
assert_equal 0, object.send(model.locking_column)
|
|
yield object.id
|
|
object.reload
|
|
assert_equal expected_count, object.test_count
|
|
assert_equal 1, object.send(model.locking_column)
|
|
ensure
|
|
remove_counter_column_from(model)
|
|
end
|
|
end
|
|
|
|
|
|
# TODO: test against the generated SQL since testing locking behavior itself
|
|
# is so cumbersome. Will deadlock Ruby threads if the underlying db.execute
|
|
# blocks, so separate script called by Kernel#system is needed.
|
|
# (See exec vs. async_exec in the PostgreSQL adapter.)
|
|
unless in_memory_db?
|
|
class PessimisticLockingTest < ActiveRecord::TestCase
|
|
self.use_transactional_fixtures = false
|
|
fixtures :people, :readers
|
|
|
|
def setup
|
|
Person.connection_pool.clear_reloadable_connections!
|
|
# Avoid introspection queries during tests.
|
|
Person.columns; Reader.columns
|
|
end
|
|
|
|
# Test typical find.
|
|
def test_sane_find_with_lock
|
|
assert_nothing_raised do
|
|
Person.transaction do
|
|
Person.lock.find(1)
|
|
end
|
|
end
|
|
end
|
|
|
|
# PostgreSQL protests SELECT ... FOR UPDATE on an outer join.
|
|
unless current_adapter?(:PostgreSQLAdapter)
|
|
# Test locked eager find.
|
|
def test_eager_find_with_lock
|
|
assert_nothing_raised do
|
|
Person.transaction do
|
|
Person.includes(:readers).lock.find(1)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# Locking a record reloads it.
|
|
def test_sane_lock_method
|
|
assert_nothing_raised do
|
|
Person.transaction do
|
|
person = Person.find 1
|
|
old, person.first_name = person.first_name, 'fooman'
|
|
person.lock!
|
|
assert_equal old, person.first_name
|
|
end
|
|
end
|
|
end
|
|
|
|
def test_with_lock_commits_transaction
|
|
person = Person.find 1
|
|
person.with_lock do
|
|
person.first_name = 'fooman'
|
|
person.save!
|
|
end
|
|
assert_equal 'fooman', person.reload.first_name
|
|
end
|
|
|
|
def test_with_lock_rolls_back_transaction
|
|
person = Person.find 1
|
|
old = person.first_name
|
|
person.with_lock do
|
|
person.first_name = 'fooman'
|
|
person.save!
|
|
raise 'oops'
|
|
end rescue nil
|
|
assert_equal old, person.reload.first_name
|
|
end
|
|
|
|
if current_adapter?(:PostgreSQLAdapter)
|
|
def test_lock_sending_custom_lock_statement
|
|
Person.transaction do
|
|
person = Person.find(1)
|
|
assert_sql(/LIMIT 1 FOR SHARE NOWAIT/) do
|
|
person.lock!('FOR SHARE NOWAIT')
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if current_adapter?(:PostgreSQLAdapter, :OracleAdapter)
|
|
def test_no_locks_no_wait
|
|
first, second = duel { Person.find 1 }
|
|
assert first.end > second.end
|
|
end
|
|
|
|
protected
|
|
def duel(zzz = 5)
|
|
t0, t1, t2, t3 = nil, nil, nil, nil
|
|
|
|
a = Thread.new do
|
|
t0 = Time.now
|
|
Person.transaction do
|
|
yield
|
|
sleep zzz # block thread 2 for zzz seconds
|
|
end
|
|
t1 = Time.now
|
|
end
|
|
|
|
b = Thread.new do
|
|
sleep zzz / 2.0 # ensure thread 1 tx starts first
|
|
t2 = Time.now
|
|
Person.transaction { yield }
|
|
t3 = Time.now
|
|
end
|
|
|
|
a.join
|
|
b.join
|
|
|
|
assert t1 > t0 + zzz
|
|
assert t2 > t0
|
|
assert t3 > t2
|
|
[t0.to_f..t1.to_f, t2.to_f..t3.to_f]
|
|
end
|
|
end
|
|
end
|
|
end
|