Skip to content

Commit cf1523f

Browse files
committed
Support cache_classes: true descendants filtering
When cache_classes is true, we can no longer rely on the Active Support dependencies tracker, so we use our own separate tracker to filter out destroyed with_model models from Class#descendants and Class#subclasses. Fixes #35
1 parent dd38e8d commit cf1523f

6 files changed

Lines changed: 143 additions & 37 deletions

File tree

.rubocop.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,8 @@ RSpec/Be:
2929
RSpec/BeforeAfterAll:
3030
Enabled: false
3131

32+
RSpec/ExpectInHook:
33+
Enabled: false
34+
3235
Style/Documentation:
3336
Enabled: false

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ end
1717
gem "bigdecimal"
1818
gem "bundler"
1919
gem "debug"
20+
gem "logger"
2021
gem "minitest"
2122
gem "mutex_m"
2223
gem "rake"
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
require "active_support/descendants_tracker"
2+
3+
module WithModel
4+
module DescendantsTracker
5+
if RUBY_ENGINE == "ruby"
6+
# On MRI `ObjectSpace::WeakMap` keys are weak references.
7+
# So we can simply use WeakMap as a `Set`.
8+
class WeakSet < ObjectSpace::WeakMap # :nodoc:
9+
alias_method :to_a, :keys
10+
11+
def <<(object)
12+
self[object] = true
13+
end
14+
end
15+
else
16+
# On TruffleRuby `ObjectSpace::WeakMap` keys are strong references.
17+
# So we use `object_id` as a key and the actual object as a value.
18+
#
19+
# JRuby for now doesn't have Class#descendant, but when it will, it will likely
20+
# have the same WeakMap semantic than Truffle so we future proof this as much as possible.
21+
class WeakSet # :nodoc:
22+
def initialize
23+
@map = ObjectSpace::WeakMap.new
24+
end
25+
26+
def [](object)
27+
@map.key?(object.object_id)
28+
end
29+
alias_method :include?, :[]
30+
31+
def []=(object, _present)
32+
@map[object.object_id] = object
33+
end
34+
35+
def to_a
36+
@map.values
37+
end
38+
39+
def <<(object)
40+
self[object] = true
41+
end
42+
end
43+
end
44+
@excluded_descendants = WeakSet.new
45+
46+
class << self
47+
def clear(classes) # :nodoc:
48+
classes.each do |klass|
49+
@excluded_descendants << klass
50+
klass.descendants.each do |descendant|
51+
@excluded_descendants << descendant
52+
end
53+
end
54+
end
55+
56+
def reject!(classes) # :nodoc:
57+
if @excluded_descendants
58+
classes.reject! { |d| @excluded_descendants.include?(d) }
59+
end
60+
classes
61+
end
62+
end
63+
64+
module ReloadedClassesFiltering # :nodoc:
65+
def subclasses
66+
WithModel::DescendantsTracker.reject!(super)
67+
end
68+
69+
def descendants
70+
WithModel::DescendantsTracker.reject!(super)
71+
end
72+
end
73+
end
74+
end
75+
76+
class Class
77+
prepend WithModel::DescendantsTracker::ReloadedClassesFiltering
78+
end
79+
80+
module ActiveSupport
81+
module DescendantsTracker
82+
class << self
83+
attr_reader :clear_disabled
84+
end
85+
end
86+
end

lib/with_model/model.rb

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# frozen_string_literal: true
22

3+
require "logger"
34
require "active_record"
45
require "active_support/core_ext/string/inflections"
56
require "English"
67
require "with_model/constant_stubber"
8+
require "with_model/descendants_tracker"
79
require "with_model/methods"
810
require "with_model/table"
911

@@ -38,6 +40,7 @@ def destroy
3840
cleanup_descendants_tracking
3941
reset_dependencies_cache
4042
table.destroy
43+
WithModel::DescendantsTracker.clear([@model])
4144
@model = nil
4245
end
4346

@@ -54,15 +57,8 @@ def setup_model
5457
end
5558

5659
def cleanup_descendants_tracking
57-
if defined?(ActiveSupport::DescendantsTracker)
58-
if ActiveSupport::VERSION::MAJOR >= 7
59-
ActiveSupport::DescendantsTracker.clear([@model])
60-
else
61-
ActiveSupport::DescendantsTracker.class_variable_get(:@@direct_descendants).delete(ActiveRecord::Base)
62-
end
63-
elsif @model.superclass.respond_to?(:direct_descendants)
64-
@model.superclass.direct_descendants.delete(@model)
65-
end
60+
ActiveSupport::DescendantsTracker.clear([@model]) \
61+
unless ActiveSupport::DescendantsTracker.clear_disabled
6662
end
6763

6864
def reset_dependencies_cache

spec/descendants_tracking_spec.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
describe "Descendants tracking" do # rubocop:disable RSpec/DescribeClass
6+
with_model :BlogPost do
7+
model do
8+
def self.inspect
9+
"BlogPost class #{object_id}"
10+
end
11+
end
12+
end
13+
14+
def blog_post_classes
15+
ActiveRecord::Base.descendants.select do |c|
16+
c.table_name == BlogPost.table_name
17+
end
18+
end
19+
20+
shared_examples "clearing descendants between test runs" do
21+
it "includes the correct model class in descendants on the first test run" do
22+
expect(blog_post_classes).to eq [BlogPost]
23+
end
24+
25+
it "includes the correct model class in descendants on the second test run" do
26+
expect(blog_post_classes).to eq [BlogPost]
27+
end
28+
end
29+
30+
context "with ActiveSupport::DescendantsTracker (cache_classes: true)" do
31+
before do
32+
expect(ActiveSupport::DescendantsTracker.clear_disabled).to be_falsey
33+
expect { ActiveSupport::DescendantsTracker.clear([]) }.not_to raise_exception
34+
end
35+
36+
include_examples "clearing descendants between test runs"
37+
end
38+
39+
context "without ActiveSupport::DescendantsTracker (cache_classes: false)" do
40+
before do
41+
ActiveSupport::DescendantsTracker.disable_clear!
42+
expect(ActiveSupport::DescendantsTracker.clear_disabled).to be_truthy
43+
expect { ActiveSupport::DescendantsTracker.clear([]) }.to raise_exception(RuntimeError)
44+
end
45+
46+
include_examples "clearing descendants between test runs"
47+
end
48+
end

spec/with_model_spec.rb

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -320,30 +320,6 @@ def my_method
320320
end
321321
end
322322

323-
context "with ActiveSupport::DescendantsTracker" do
324-
with_model :BlogPost do
325-
model do
326-
def self.inspect
327-
"BlogPost class #{object_id}"
328-
end
329-
end
330-
end
331-
332-
def blog_post_classes
333-
ActiveRecord::Base.descendants.select do |c|
334-
c.table_name == BlogPost.table_name
335-
end
336-
end
337-
338-
it "includes the correct model class in descendants on the first test run" do
339-
expect(blog_post_classes).to eq [BlogPost]
340-
end
341-
342-
it "includes the correct model class in descendants on the second test run" do
343-
expect(blog_post_classes).to eq [BlogPost]
344-
end
345-
end
346-
347323
context "with_model can be run within RSpec :all hook" do
348324
with_model :BlogPost, scope: :all do
349325
table do |t|
@@ -396,10 +372,6 @@ class ApplicationRecordInDifferentDatabase < ActiveRecord::Base # standard:disab
396372
establish_connection(ActiveRecord::Base.connection_pool.db_config.configuration_hash)
397373
end
398374

399-
after(:all) do
400-
Object.__send__(:remove_const, "ApplicationRecordInDifferentDatabase")
401-
end
402-
403375
with_model :BlogPost, superclass: ApplicationRecordInDifferentDatabase do
404376
table do |t|
405377
t.string "title"

0 commit comments

Comments
 (0)