Avoid validating a unique field if it has not changed and is backed by a unique index

This commit is contained in:
fatkodima 2022-05-22 02:41:05 +03:00
parent 3404606378
commit c2bdc6b0c2
3 changed files with 227 additions and 0 deletions

View File

@ -1,3 +1,12 @@
* Avoid validating a unique field if it has not changed and is backed by a unique index.
Previously, when saving a record, ActiveRecord will perform an extra query to check for the uniqueness of
each attribute having a `uniqueness` validation, even if that attribute hasn't changed.
If the database has the corresponding unique index, then this validation can never fail for persisted records,
and we could safely skip it.
*fatkodima*
* Stop setting `sql_auto_is_null`
Since version 5.5 the default has been off, we no longer have to manually turn it off.

View File

@ -20,6 +20,8 @@ module ActiveRecord
finder_class = find_finder_class_for(record)
value = map_enum_attribute(finder_class, attribute, value)
return if record.persisted? && !validation_needed?(finder_class, record, attribute)
relation = build_relation(finder_class, attribute, value)
if record.persisted?
if finder_class.primary_key
@ -64,6 +66,48 @@ module ActiveRecord
class_hierarchy.detect { |klass| !klass.abstract_class? }
end
def validation_needed?(klass, record, attribute)
return true if options[:conditions] || options.key?(:case_sensitive)
scope = Array(options[:scope])
attributes = scope + [attribute]
attributes = resolve_attributes(record, attributes)
return true if attributes.any? { |attr| record.attribute_changed?(attr) ||
record.read_attribute(attr).nil? }
!covered_by_unique_index?(klass, record, attribute, scope)
end
def covered_by_unique_index?(klass, record, attribute, scope)
@covered ||= self.attributes.map(&:to_s).select do |attr|
attributes = scope + [attr]
attributes = resolve_attributes(record, attributes)
klass.connection.schema_cache.indexes(klass.table_name).any? do |index|
index.unique &&
index.where.nil? &&
(index.columns - attributes).empty?
end
end
@covered.include?(attribute.to_s)
end
def resolve_attributes(record, attributes)
attributes.flat_map do |attribute|
reflection = record.class._reflect_on_association(attribute)
if reflection.nil?
attribute.to_s
elsif reflection.polymorphic?
[reflection.foreign_key, reflection.foreign_type]
else
reflection.foreign_key
end
end
end
def build_relation(klass, attribute, value)
relation = klass.unscoped
comparison = relation.bind_attribute(attribute, value) do |attr, bind|

View File

@ -36,6 +36,10 @@ class ReplyWithTitleObject < Reply
def title; ReplyTitle.new; end
end
class TopicWithEvent < Topic
belongs_to :event, foreign_key: :parent_id
end
class TopicWithUniqEvent < Topic
belongs_to :event, foreign_key: :parent_id
validates :event, uniqueness: true
@ -618,3 +622,173 @@ class UniquenessValidationTest < ActiveRecord::TestCase
assert_equal(["has already been taken"], item2.errors[:id])
end
end
class UniquenessValidationWithIndexTest < ActiveRecord::TestCase
self.use_transactional_tests = false
def setup
@connection = Topic.connection
@connection.schema_cache.clear!
Topic.delete_all
Event.delete_all
end
def teardown
Topic.clear_validators!
@connection.remove_index(:topics, name: :topics_index, if_exists: true)
end
def test_new_record
Topic.validates_uniqueness_of(:title)
@connection.add_index(:topics, :title, unique: true, name: :topics_index)
t = Topic.new(title: "abc")
assert_queries(1) do
t.valid?
end
end
def test_changing_non_unique_attribute
Topic.validates_uniqueness_of(:title)
@connection.add_index(:topics, :title, unique: true, name: :topics_index)
t = Topic.create!(title: "abc")
t.author_name = "John"
assert_no_queries(ignore_none: false) do
t.valid?
end
end
def test_changing_unique_attribute
Topic.validates_uniqueness_of(:title)
@connection.add_index(:topics, :title, unique: true, name: :topics_index)
t = Topic.create!(title: "abc")
t.title = "abc v2"
assert_queries(1) do
t.valid?
end
end
def test_changing_non_unique_attribute_and_unique_attribute_is_nil
Topic.validates_uniqueness_of(:title)
@connection.add_index(:topics, :title, unique: true, name: :topics_index)
t = Topic.create!
assert_nil t.title
t.author_name = "John"
assert_queries(1) do
t.valid?
end
end
def test_conditions
Topic.validates_uniqueness_of(:title, conditions: -> { where.not(author_name: nil) })
@connection.add_index(:topics, :title, unique: true, name: :topics_index)
t = Topic.create!(title: "abc")
t.title = "abc v2"
assert_queries(1) do
t.valid?
end
end
def test_case_sensitive
Topic.validates_uniqueness_of(:title, case_sensitive: true)
@connection.add_index(:topics, :title, unique: true, name: :topics_index)
t = Topic.create!(title: "abc")
t.title = "abc v2"
assert_queries(1) do
t.valid?
end
end
def test_partial_index
skip unless @connection.supports_partial_index?
Topic.validates_uniqueness_of(:title)
@connection.add_index(:topics, :title, unique: true, where: "approved", name: :topics_index)
t = Topic.create!(title: "abc")
t.author_name = "John"
assert_queries(1) do
t.valid?
end
end
def test_non_unique_index
Topic.validates_uniqueness_of(:title)
@connection.add_index(:topics, :title, name: :topics_index)
t = Topic.create!(title: "abc")
t.author_name = "John"
assert_queries(1) do
t.valid?
end
end
def test_scope
Topic.validates_uniqueness_of(:title, scope: :author_name)
@connection.add_index(:topics, [:author_name, :title], unique: true, name: :topics_index)
t = Topic.create!(title: "abc", author_name: "John")
t.content = "hello world"
assert_no_queries(ignore_none: false) do
t.valid?
end
t.author_name = "Amy"
assert_queries(1) do
t.valid?
end
end
def test_uniqueness_on_relation
TopicWithEvent.validates_uniqueness_of(:event)
@connection.add_index(:topics, :parent_id, unique: true, name: :topics_index)
e1 = Event.create!(title: "abc")
e2 = Event.create!(title: "cde")
t = TopicWithEvent.create!(event: e1)
t.content = "hello world"
assert_no_queries(ignore_none: false) do
t.valid?
end
t.event = e2
assert_queries(1) do
t.valid?
end
ensure
TopicWithEvent.clear_validators!
end
def test_index_of_sublist_of_columns
Topic.validates_uniqueness_of(:title, scope: :author_name)
@connection.add_index(:topics, :author_name, unique: true, name: :topics_index)
t = Topic.create!(title: "abc", author_name: "John")
t.content = "hello world"
assert_no_queries(ignore_none: false) do
t.valid?
end
t.author_name = "Amy"
assert_queries(1, ignore_none: false) do
t.valid?
end
end
def test_index_of_columns_list_and_extra_columns
Topic.validates_uniqueness_of(:title)
@connection.add_index(:topics, [:title, :author_name], unique: true, name: :topics_index)
t = Topic.create!(title: "abc", author_name: "John")
t.content = "hello world"
assert_queries(1) do
t.valid?
end
end
end