Skip to content

Commit bceb623

Browse files
committed
Use separate Relation subclasses for each AR class
At present, ActiveRecord::Delegation compiles delegation methods on a global basis. The compiled methods apply to all subsequent Relation instances. This creates several problems: 1) After Post.all.recent has been called, User.all.respond_to?(:recent) will be true, even if User.all.recent will actually raise an error due to no User.recent method existing. (See rails#8080.) 2) Depending on the AR class, the delegation should do different things. For example, if a Post.zip method exists, then Post.all.zip should call it. But this will then result in User.zip being called by a subsequent User.all.zip, even if User.zip does not exist, when in fact User.all.zip should call User.all.to_a.zip. (There are various variants of this problem.) We are creating these compiled delegations in order to avoid method missing and to avoid repeating logic on each invocation. One way of handling these issues is to add additional checks in various places to ensure we're doing the "right thing". However, this makes the compiled methods signficantly slower. In which case, there's almost no point in avoiding method_missing at all. (See rails#8127 for a proposed solution which takes this approach.) This is an alternative approach which involves creating a subclass of ActiveRecord::Relation for each AR class represented. So, with this patch, Post.all.class != User.all.class. This means that the delegations are compiled for and only apply to a single AR class. A compiled method for Post.all will not be invoked from User.all. This solves the above issues without incurring significant performance penalties. It's designed to be relatively seamless, however the downside is a bit of complexity and potentially confusion for a user who thinks that Post.all and User.all should be instances of the same class. Benchmark --------- require 'active_record' require 'benchmark/ips' class Post < ActiveRecord::Base establish_connection adapter: 'sqlite3', database: ':memory:' connection.create_table :posts def self.omg :omg end end relation = Post.all Benchmark.ips do |r| r.report('delegation') { relation.omg } r.report('constructing') { Post.all } end Before ------ Calculating ------------------------------------- delegation 4392 i/100ms constructing 4780 i/100ms ------------------------------------------------- delegation 144235.9 (±27.7%) i/s - 663192 in 5.038075s constructing 182015.5 (±21.2%) i/s - 850840 in 5.005364s After ----- Calculating ------------------------------------- delegation 6677 i/100ms constructing 6260 i/100ms ------------------------------------------------- delegation 166828.2 (±34.2%) i/s - 754501 in 5.001430s constructing 116575.5 (±18.6%) i/s - 563400 in 5.036690s Comments -------- Bear in mind that the standard deviations in the above are huge, so we can't compare the numbers too directly. However, we can conclude that Relation construction has become a little slower (as we'd expect), but not by a huge huge amount, and we can still construct a large number of Relations quite quickly.
1 parent b313bcb commit bceb623

File tree

5 files changed

+150
-56
lines changed

5 files changed

+150
-56
lines changed

activerecord/lib/active_record/associations/collection_association.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def reader(force_reload = false)
3434
reload
3535
end
3636

37-
CollectionProxy.new(self)
37+
CollectionProxy.new(klass, self)
3838
end
3939

4040
# Implements the writer method, e.g. foo.items= for Foo.has_many :items

activerecord/lib/active_record/associations/collection_proxy.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ module Associations
3030
class CollectionProxy < Relation
3131
delegate(*(ActiveRecord::Calculations.public_instance_methods - [:count]), to: :scope)
3232

33-
def initialize(association) #:nodoc:
33+
def initialize(klass, association) #:nodoc:
3434
@association = association
35-
super association.klass, association.klass.arel_table
35+
super klass, klass.arel_table
3636
merge! association.scope(nullify: false)
3737
end
3838

activerecord/lib/active_record/relation/delegation.rb

Lines changed: 94 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,107 @@
1-
require 'thread'
1+
require 'active_support/concern'
22

33
module ActiveRecord
44
module Delegation # :nodoc:
5-
# Set up common delegations for performance (avoids method_missing)
5+
extend ActiveSupport::Concern
6+
67
delegate :to_xml, :to_yaml, :length, :collect, :map, :each, :all?, :include?, :to_ary, :to => :to_a
78
delegate :table_name, :quoted_table_name, :primary_key, :quoted_primary_key,
89
:connection, :columns_hash, :auto_explain_threshold_in_seconds, :to => :klass
910

10-
@@delegation_mutex = Mutex.new
11+
module ClassSpecificRelation
12+
extend ActiveSupport::Concern
13+
14+
included do
15+
@delegation_mutex = Mutex.new
16+
end
17+
18+
module ClassMethods
19+
def name
20+
superclass.name
21+
end
1122

12-
def self.delegate_to_scoped_klass(method)
13-
if method.to_s =~ /\A[a-zA-Z_]\w*[!?]?\z/
14-
module_eval <<-RUBY, __FILE__, __LINE__ + 1
15-
def #{method}(*args, &block)
16-
scoping { @klass.#{method}(*args, &block) }
23+
def delegate_to_scoped_klass(method)
24+
@delegation_mutex.synchronize do
25+
return if method_defined?(method)
26+
27+
if method.to_s =~ /\A[a-zA-Z_]\w*[!?]?\z/
28+
module_eval <<-RUBY, __FILE__, __LINE__ + 1
29+
def #{method}(*args, &block)
30+
scoping { @klass.#{method}(*args, &block) }
31+
end
32+
RUBY
33+
else
34+
module_eval <<-RUBY, __FILE__, __LINE__ + 1
35+
def #{method}(*args, &block)
36+
scoping { @klass.send(#{method.inspect}, *args, &block) }
37+
end
38+
RUBY
39+
end
1740
end
18-
RUBY
19-
else
20-
module_eval <<-RUBY, __FILE__, __LINE__ + 1
21-
def #{method}(*args, &block)
22-
scoping { @klass.send(#{method.inspect}, *args, &block) }
41+
end
42+
43+
def delegate(method, opts = {})
44+
@delegation_mutex.synchronize do
45+
return if method_defined?(method)
46+
super
47+
end
48+
end
49+
end
50+
51+
protected
52+
53+
def method_missing(method, *args, &block)
54+
if @klass.respond_to?(method)
55+
self.class.delegate_to_scoped_klass(method)
56+
scoping { @klass.send(method, *args, &block) }
57+
elsif Array.method_defined?(method)
58+
self.class.delegate method, :to => :to_a
59+
to_a.send(method, *args, &block)
60+
elsif arel.respond_to?(method)
61+
self.class.delegate method, :to => :arel
62+
arel.send(method, *args, &block)
63+
else
64+
super
65+
end
66+
end
67+
end
68+
69+
module ClassMethods
70+
@@mutex = Mutex.new
71+
@@subclasses = Hash.new { |h, k| h[k] = {} }
72+
73+
def new(klass, *args)
74+
relation = relation_class_for(klass).allocate
75+
relation.__send__(:initialize, klass, *args)
76+
relation
77+
end
78+
79+
# Cache the constants in @@subclasses because looking them up via const_get
80+
# make instantiation significantly slower.
81+
def relation_class_for(klass)
82+
if klass && klass.name
83+
if subclass = @@mutex.synchronize { @@subclasses[self][klass] }
84+
subclass
85+
else
86+
subclass = const_get("#{name.gsub('::', '_')}_#{klass.name.gsub('::', '_')}", false)
87+
@@mutex.synchronize { @@subclasses[self][klass] = subclass }
88+
subclass
2389
end
24-
RUBY
90+
else
91+
ActiveRecord::Relation
92+
end
93+
end
94+
95+
# Check const_defined? in case another thread has already defined the constant
96+
# I am not sure whether this is strictly necessary.
97+
def const_missing(name)
98+
@@mutex.synchronize {
99+
if const_defined?(name)
100+
const_get(name)
101+
else
102+
const_set(name, Class.new(self) { include ClassSpecificRelation })
103+
end
104+
}
25105
end
26106
end
27107

@@ -35,28 +115,10 @@ def respond_to?(method, include_private = false)
35115

36116
def method_missing(method, *args, &block)
37117
if @klass.respond_to?(method)
38-
@@delegation_mutex.synchronize do
39-
unless ::ActiveRecord::Delegation.method_defined?(method)
40-
::ActiveRecord::Delegation.delegate_to_scoped_klass(method)
41-
end
42-
end
43-
44118
scoping { @klass.send(method, *args, &block) }
45119
elsif Array.method_defined?(method)
46-
@@delegation_mutex.synchronize do
47-
unless ::ActiveRecord::Delegation.method_defined?(method)
48-
::ActiveRecord::Delegation.delegate method, :to => :to_a
49-
end
50-
end
51-
52120
to_a.send(method, *args, &block)
53121
elsif arel.respond_to?(method)
54-
@@delegation_mutex.synchronize do
55-
unless ::ActiveRecord::Delegation.method_defined?(method)
56-
::ActiveRecord::Delegation.delegate method, :to => :arel
57-
end
58-
end
59-
60122
arel.send(method, *args, &block)
61123
else
62124
super

activerecord/test/cases/relation_test.rb

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,46 +6,46 @@ module ActiveRecord
66
class RelationTest < ActiveRecord::TestCase
77
fixtures :posts, :comments
88

9-
class FakeKlass < Struct.new(:table_name)
9+
class FakeKlass < Struct.new(:table_name, :name)
1010
end
1111

1212
def test_construction
1313
relation = nil
1414
assert_nothing_raised do
15-
relation = Relation.new :a, :b
15+
relation = Relation.new FakeKlass, :b
1616
end
17-
assert_equal :a, relation.klass
17+
assert_equal FakeKlass, relation.klass
1818
assert_equal :b, relation.table
1919
assert !relation.loaded, 'relation is not loaded'
2020
end
2121

2222
def test_responds_to_model_and_returns_klass
23-
relation = Relation.new :a, :b
24-
assert_equal :a, relation.model
23+
relation = Relation.new FakeKlass, :b
24+
assert_equal FakeKlass, relation.model
2525
end
2626

2727
def test_initialize_single_values
28-
relation = Relation.new :a, :b
28+
relation = Relation.new FakeKlass, :b
2929
(Relation::SINGLE_VALUE_METHODS - [:create_with]).each do |method|
3030
assert_nil relation.send("#{method}_value"), method.to_s
3131
end
3232
assert_equal({}, relation.create_with_value)
3333
end
3434

3535
def test_multi_value_initialize
36-
relation = Relation.new :a, :b
36+
relation = Relation.new FakeKlass, :b
3737
Relation::MULTI_VALUE_METHODS.each do |method|
3838
assert_equal [], relation.send("#{method}_values"), method.to_s
3939
end
4040
end
4141

4242
def test_extensions
43-
relation = Relation.new :a, :b
43+
relation = Relation.new FakeKlass, :b
4444
assert_equal [], relation.extensions
4545
end
4646

4747
def test_empty_where_values_hash
48-
relation = Relation.new :a, :b
48+
relation = Relation.new FakeKlass, :b
4949
assert_equal({}, relation.where_values_hash)
5050

5151
relation.where! :hello
@@ -79,7 +79,7 @@ def test_table_name_delegates_to_klass
7979
end
8080

8181
def test_scope_for_create
82-
relation = Relation.new :a, :b
82+
relation = Relation.new FakeKlass, :b
8383
assert_equal({}, relation.scope_for_create)
8484
end
8585

@@ -110,66 +110,66 @@ def test_scope_for_create_is_cached
110110
end
111111

112112
def test_empty_eager_loading?
113-
relation = Relation.new :a, :b
113+
relation = Relation.new FakeKlass, :b
114114
assert !relation.eager_loading?
115115
end
116116

117117
def test_eager_load_values
118-
relation = Relation.new :a, :b
118+
relation = Relation.new FakeKlass, :b
119119
relation.eager_load! :b
120120
assert relation.eager_loading?
121121
end
122122

123123
def test_references_values
124-
relation = Relation.new :a, :b
124+
relation = Relation.new FakeKlass, :b
125125
assert_equal [], relation.references_values
126126
relation = relation.references(:foo).references(:omg, :lol)
127127
assert_equal ['foo', 'omg', 'lol'], relation.references_values
128128
end
129129

130130
def test_references_values_dont_duplicate
131-
relation = Relation.new :a, :b
131+
relation = Relation.new FakeKlass, :b
132132
relation = relation.references(:foo).references(:foo)
133133
assert_equal ['foo'], relation.references_values
134134
end
135135

136136
test 'merging a hash into a relation' do
137-
relation = Relation.new :a, :b
137+
relation = Relation.new FakeKlass, :b
138138
relation = relation.merge where: :lol, readonly: true
139139

140140
assert_equal [:lol], relation.where_values
141141
assert_equal true, relation.readonly_value
142142
end
143143

144144
test 'merging an empty hash into a relation' do
145-
assert_equal [], Relation.new(:a, :b).merge({}).where_values
145+
assert_equal [], Relation.new(FakeKlass, :b).merge({}).where_values
146146
end
147147

148148
test 'merging a hash with unknown keys raises' do
149149
assert_raises(ArgumentError) { Relation::HashMerger.new(nil, omg: 'lol') }
150150
end
151151

152152
test '#values returns a dup of the values' do
153-
relation = Relation.new(:a, :b).where! :foo
153+
relation = Relation.new(FakeKlass, :b).where! :foo
154154
values = relation.values
155155

156156
values[:where] = nil
157157
assert_not_nil relation.where_values
158158
end
159159

160160
test 'relations can be created with a values hash' do
161-
relation = Relation.new(:a, :b, where: [:foo])
161+
relation = Relation.new(FakeKlass, :b, where: [:foo])
162162
assert_equal [:foo], relation.where_values
163163
end
164164

165165
test 'merging a single where value' do
166-
relation = Relation.new(:a, :b)
166+
relation = Relation.new(FakeKlass, :b)
167167
relation.merge!(where: :foo)
168168
assert_equal [:foo], relation.where_values
169169
end
170170

171171
test 'merging a hash interpolates conditions' do
172-
klass = stub
172+
klass = stub_everything
173173
klass.stubs(:sanitize_sql).with(['foo = ?', 'bar']).returns('foo = bar')
174174

175175
relation = Relation.new(klass, :b)
@@ -179,8 +179,11 @@ def test_references_values_dont_duplicate
179179
end
180180

181181
class RelationMutationTest < ActiveSupport::TestCase
182+
class FakeKlass < Struct.new(:table_name, :name)
183+
end
184+
182185
def relation
183-
@relation ||= Relation.new :a, :b
186+
@relation ||= Relation.new FakeKlass, :b
184187
end
185188

186189
(Relation::MULTI_VALUE_METHODS - [:references, :extending]).each do |method|

activerecord/test/cases/relations_test.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,4 +1452,33 @@ def test_presence
14521452
assert_equal expected, result
14531453
end
14541454
end
1455+
1456+
test "delegations do not leak to other classes" do
1457+
Topic.all.by_lifo
1458+
assert Topic.all.class.method_defined?(:by_lifo)
1459+
assert !Post.all.respond_to?(:by_lifo)
1460+
end
1461+
1462+
class OMGTopic < ActiveRecord::Base
1463+
self.table_name = 'topics'
1464+
1465+
def self.__omg__
1466+
"omgtopic"
1467+
end
1468+
end
1469+
1470+
test "delegations do not clash across classes" do
1471+
begin
1472+
class ::Array
1473+
def __omg__
1474+
"array"
1475+
end
1476+
end
1477+
1478+
assert_equal "array", Topic.all.__omg__
1479+
assert_equal "omgtopic", OMGTopic.all.__omg__
1480+
ensure
1481+
Array.send(:remove_method, :__omg__)
1482+
end
1483+
end
14551484
end

0 commit comments

Comments
 (0)