1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228
|
# frozen_string_literal: true
module ActiveRecord
module Locking
# == What is \Optimistic \Locking
#
# Optimistic locking allows multiple users to access the same record for edits, and assumes a minimum of
# conflicts with the data. It does this by checking whether another process has made changes to a record since
# it was opened, an ActiveRecord::StaleObjectError exception is thrown if that has occurred
# and the update is ignored.
#
# Check out +ActiveRecord::Locking::Pessimistic+ for an alternative.
#
# == Usage
#
# Active Record supports optimistic locking if the +lock_version+ field is present. Each update to the
# record increments the integer column +lock_version+ 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 an ActiveRecord::StaleObjectError
#
# Optimistic locking will also check for stale data when objects are destroyed. Example:
#
# p1 = Person.find(1)
# p2 = Person.find(1)
#
# p1.first_name = "Michael"
# p1.save
#
# p2.destroy # Raises an 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.
#
# This locking mechanism will function inside a single Ruby process. To make it work across all
# web requests, the recommended approach is to add +lock_version+ as a hidden field to your form.
#
# This behavior can be turned off by setting <tt>ActiveRecord::Base.lock_optimistically = false</tt>.
# To override the name of the +lock_version+ column, set the <tt>locking_column</tt> class attribute:
#
# class Person < ActiveRecord::Base
# self.locking_column = :lock_person
# end
#
module Optimistic
extend ActiveSupport::Concern
included do
class_attribute :lock_optimistically, instance_writer: false, default: true
end
def locking_enabled? # :nodoc:
self.class.locking_enabled?
end
def increment!(*, **) # :nodoc:
super.tap do
if locking_enabled?
self[self.class.locking_column] += 1
clear_attribute_change(self.class.locking_column)
end
end
end
def initialize_dup(other) # :nodoc:
super
_clear_locking_column if locking_enabled?
end
private
def _create_record(attribute_names = self.attribute_names)
if locking_enabled?
# We always want to persist the locking version, even if we don't detect
# a change from the default, since the database might have no default
attribute_names |= [self.class.locking_column]
end
super
end
def _touch_row(attribute_names, time)
@_touch_attr_names << self.class.locking_column if locking_enabled?
super
end
def _update_row(attribute_names, attempted_action = "update")
return super unless locking_enabled?
begin
locking_column = self.class.locking_column
lock_attribute_was = @attributes[locking_column]
update_constraints = _query_constraints_hash
attribute_names = attribute_names.dup if attribute_names.frozen?
attribute_names << locking_column
self[locking_column] += 1
affected_rows = self.class._update_record(
attributes_with_values(attribute_names),
update_constraints
)
if affected_rows != 1
raise ActiveRecord::StaleObjectError.new(self, attempted_action)
end
affected_rows
# If something went wrong, revert the locking_column value.
rescue Exception
@attributes[locking_column] = lock_attribute_was
raise
end
end
def destroy_row
affected_rows = super
if locking_enabled? && affected_rows != 1
raise ActiveRecord::StaleObjectError.new(self, "destroy")
end
affected_rows
end
def _lock_value_for_database(locking_column)
if will_save_change_to_attribute?(locking_column)
@attributes[locking_column].value_for_database
else
@attributes[locking_column].original_value_for_database
end
end
def _clear_locking_column
self[self.class.locking_column] = nil
clear_attribute_change(self.class.locking_column)
end
def _query_constraints_hash
return super unless locking_enabled?
locking_column = self.class.locking_column
super.merge(locking_column => _lock_value_for_database(locking_column))
end
module ClassMethods
DEFAULT_LOCKING_COLUMN = "lock_version"
# Returns true if the +lock_optimistically+ flag is set to true
# (which it is, by default) and the table includes the
# +locking_column+ column (defaults to +lock_version+).
def locking_enabled?
lock_optimistically && columns_hash[locking_column]
end
# Set the column to use for optimistic locking. Defaults to +lock_version+.
def locking_column=(value)
reload_schema_from_cache
@locking_column = value.to_s
end
# The version column used for optimistic locking. Defaults to +lock_version+.
attr_reader :locking_column
# Reset the column used for optimistic locking back to the +lock_version+ default.
def reset_locking_column
self.locking_column = DEFAULT_LOCKING_COLUMN
end
# Make sure the lock version column gets updated when counters are
# updated.
def update_counters(id, counters)
counters = counters.merge(locking_column => 1) if locking_enabled?
super
end
private
def hook_attribute_type(name, cast_type)
if lock_optimistically && name == locking_column
cast_type = LockingType.new(cast_type)
end
super
end
def inherited(base)
super
base.class_eval do
@locking_column = DEFAULT_LOCKING_COLUMN
end
end
end
end
# In de/serialize we change `nil` to 0, so that we can allow passing
# `nil` values to `lock_version`, and not result in `ActiveRecord::StaleObjectError`
# during update record.
class LockingType < DelegateClass(Type::Value) # :nodoc:
def self.new(subtype)
self === subtype ? subtype : super
end
def deserialize(value)
super.to_i
end
def serialize(value)
super.to_i
end
def init_with(coder)
__setobj__(coder["subtype"])
end
def encode_with(coder)
coder["subtype"] = __getobj__
end
end
end
end
|