Skip to content

Commit 092747b

Browse files
authored
fix: Prevent ActiveRecord constant leak in uniqueness matcher (#1694)
When using the validate_uniqueness_of matcher, test model classes were being added to ActiveRecord::Base.descendants and not being removed after the matcher ran. This could lead to unexpected behavior in tests that rely on the list of ActiveRecord descendants. Closes #1663
1 parent 71fa0fc commit 092747b

File tree

2 files changed

+48
-2
lines changed

2 files changed

+48
-2
lines changed

lib/shoulda/matchers/active_record/uniqueness/namespace.rb

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,33 @@ def initialize(constant)
1010
end
1111

1212
def has?(name)
13-
constant.const_defined?(name)
13+
constant.const_defined?(name, false)
1414
end
1515

1616
def set(name, value)
1717
constant.const_set(name, value)
1818
end
1919

2020
def clear
21+
test_model_classes = []
22+
2123
constant.constants.each do |child_constant|
22-
constant.__send__(:remove_const, child_constant)
24+
child = constant.const_get(child_constant)
25+
26+
if defined?(::ActiveRecord::Base) &&
27+
child.is_a?(Class) &&
28+
child < ::ActiveRecord::Base
29+
test_model_classes << child
30+
end
31+
32+
constant.remove_const(child_constant)
33+
rescue NameError
34+
# Constant may have been removed elsewhere; ignore
35+
end
36+
37+
if defined?(::ActiveSupport::DescendantsTracker) &&
38+
test_model_classes.any?
39+
::ActiveSupport::DescendantsTracker.clear(test_model_classes)
2340
end
2441
end
2542

spec/unit/shoulda/matchers/active_record/validate_uniqueness_of_matcher_spec.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1539,6 +1539,35 @@ def name=(name)
15391539
end
15401540
end
15411541

1542+
context 'test model cleanup' do
1543+
it 'removes test models from ActiveRecord::Base.descendants after validation' do
1544+
descendants_before = ActiveRecord::Base.descendants.map(&:to_s)
1545+
1546+
model = define_model :example, owner_id: :integer, owner_type: :string do |m|
1547+
m.belongs_to :owner, polymorphic: true
1548+
m.validates_uniqueness_of :owner_id, scope: :owner_type
1549+
end
1550+
1551+
existing_owner_model = define_model(:user)
1552+
owner = existing_owner_model.create!
1553+
model.create!(owner:)
1554+
1555+
record = model.new
1556+
1557+
expect(record).to validate_uniqueness_of(:owner_id).scoped_to(:owner_type)
1558+
1559+
descendants_after = ActiveRecord::Base.descendants.map(&:to_s)
1560+
new_descendants = descendants_after - descendants_before
1561+
1562+
test_model_descendants = new_descendants.select do |name|
1563+
name.start_with?('Shoulda::Matchers::ActiveRecord::Uniqueness::TestModels::')
1564+
end
1565+
1566+
expect(test_model_descendants).to be_empty,
1567+
"Expected no test models in ActiveRecord::Base.descendants, but found: #{test_model_descendants.join(', ')}"
1568+
end
1569+
end
1570+
15421571
let(:model_attributes) { {} }
15431572

15441573
def default_attribute

0 commit comments

Comments
 (0)