Skip to content

Commit ab61f0c

Browse files
committed
Teach the ActiveRecord::Associations::Preloader some new tricks
1 parent 167da79 commit ab61f0c

File tree

2 files changed

+222
-0
lines changed

2 files changed

+222
-0
lines changed
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright 2025 Roy Liu
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
6+
# use this file except in compliance with the License. You may obtain a copy of
7+
# the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations under
15+
# the License.
16+
17+
module Scalient
18+
module ActiveRecord
19+
module PreloaderBranchMonkeyPatch
20+
SPECIAL_LITERAL_SYNTAX_PATTERN = Regexp.new("\\A(?<prefix>[\\.:])(?<name>.*)\\z")
21+
22+
def self.apply(clazz)
23+
outer_self = self
24+
25+
clazz.class_eval do
26+
prepend outer_self
27+
end
28+
end
29+
30+
def selects
31+
@selects ||= []
32+
end
33+
34+
def polymorphic_specializations_to_subtrees
35+
@polymorphic_specializations_to_subtrees ||= {}
36+
end
37+
38+
def polymorphic_specializations_to_subtrees=(polymorphic_specializations_to_subtrees)
39+
@polymorphic_specializations_to_subtrees = polymorphic_specializations_to_subtrees
40+
end
41+
42+
# Overridden to preprocess special preload syntax and store those directives in auxiliary data structures, while
43+
# passing the normal syntax to the superclass method. Appends polymorphic specialization subtrees to the result so
44+
# that loading can proceed conditionally based on detected foreign types.
45+
def build_children(preloads)
46+
preloads = preloads.deep_dup
47+
48+
if preloads
49+
preprocess_preloads!(preloads)
50+
end
51+
52+
# Add the polymorphic specialization subtrees for consideration when later code interrogates subtrees for their
53+
# loaders.
54+
super + polymorphic_specializations_to_subtrees.values
55+
end
56+
57+
# Preprocess the preload specification by populating auxiliary data structures and stripping out syntax that would
58+
# otherwise cause the regular preloader to choke.
59+
def preprocess_preloads!(preloads)
60+
case preloads
61+
when Hash
62+
preloads.select! do |reflection_name, sub_preloads|
63+
preprocess_literal(reflection_name, sub_preloads)
64+
end
65+
66+
preloads.size > 0
67+
when Array
68+
preloads.select! do |sub_preloads|
69+
preprocess_preloads!(sub_preloads)
70+
end
71+
72+
preloads.size > 0
73+
when String, Symbol
74+
preprocess_literal(preloads)
75+
else
76+
raise ArgumentError, "Invalid preload specification"
77+
end
78+
end
79+
80+
# Preprocess a literal that is usually a reflection name, but sometimes is `.#{attribute_name}` or
81+
# `:#{class_name}`.
82+
def preprocess_literal(literal, preloads = nil)
83+
literal = literal.to_s
84+
85+
if (m = SPECIAL_LITERAL_SYNTAX_PATTERN.match(literal))
86+
case m[:prefix]
87+
when "."
88+
# A `.#{attribute_name}` literal has been detected: This means that we want to selectively load the column
89+
# `attribute_name` in the parent relation's projection.
90+
selects.push(m[:name])
91+
when ":"
92+
name = m[:name]
93+
specialization_class = name.safe_constantize
94+
95+
if !specialization_class
96+
raise ArgumentError, "No constant for class name #{name.dump} found"
97+
end
98+
99+
# A `:#{class_name}` literal has been detected: This means that said preloads are for the specialized type
100+
# `class_name` under polymorphism. In effect, we disambiguate the nested reflections and attributes of the
101+
# single, overloaded polymorphic reflection in Rails' implementation with potentially different nested
102+
# reflections and attributes for each specialized type.
103+
polymorphic_specializations_to_subtrees[name] = self.class.new(
104+
parent:,
105+
association:,
106+
children: preloads,
107+
associate_by_default:,
108+
scope:,
109+
).tap do |child|
110+
# Share the original map with specialized children.
111+
child.polymorphic_specializations_to_subtrees = polymorphic_specializations_to_subtrees
112+
end
113+
else
114+
raise "Control should never reach here"
115+
end
116+
117+
# Signal to the caller to reject the item: We already gave it special treatment.
118+
false
119+
else
120+
# Signal to the caller to keep the item.
121+
true
122+
end
123+
end
124+
125+
# Overridden to inject the columns referenced in `#selects` into the query scope's projection.
126+
#
127+
# TODO: The library currently doesn't automatically include primary keys and association foreign keys when using
128+
# dot syntax for selective preloading; those are the user's responsibility for now.
129+
def preloaders_for_reflection(reflection, reflection_records)
130+
reflection_records.group_by do |record|
131+
klass = record.association(association).klass
132+
133+
if reflection.scope && reflection.scope.arity != 0
134+
# For instance dependent scopes, the scope is potentially
135+
# different for each record. To allow this we'll group each
136+
# object separately into its own preloader
137+
reflection_scope = reflection.join_scopes(
138+
klass.arel_table, klass.predicate_builder, klass, record,
139+
).inject(&:merge!)
140+
end
141+
142+
[klass, reflection_scope]
143+
end.map do |(rhs_klass, reflection_scope), rs|
144+
# Start off the preloader's scope.
145+
preloader_scope = rhs_klass.select(selects)
146+
147+
# Merge in the explicitly provided scope.
148+
if scope
149+
preloader_scope.merge!(scope)
150+
end
151+
152+
preloader_for(reflection).new(
153+
rhs_klass, rs, reflection, preloader_scope, reflection_scope, associate_by_default,
154+
)
155+
end
156+
end
157+
158+
# Overridden to consult polymorphic specialization subtrees if they are present in composite hash keys.
159+
def loaders
160+
# Try to destructure subtrees corresponding to polymorphic specializations.
161+
@loaders ||=
162+
grouped_records.flat_map do |(reflection, subtree), reflection_records|
163+
(!subtree ? self : subtree).preloaders_for_reflection(reflection, reflection_records)
164+
end
165+
end
166+
167+
# Overridden to match records with potential polymorphic specializations.
168+
def grouped_records
169+
h = {}
170+
polymorphic_parent = !root? && parent.polymorphic?
171+
source_records.each do |record|
172+
reflection = record.class._reflect_on_association(association)
173+
next if (polymorphic_parent && !reflection) || !record.association(association).klass
174+
175+
# If the reflection is a registered polymorphic specialization, include subtree information by setting a
176+
# composite hash key.
177+
key = if reflection.polymorphic? && polymorphic_specializations_to_subtrees.size > 0
178+
subtree = polymorphic_specializations_to_subtrees[record.read_attribute(reflection.foreign_type)]
179+
180+
if self == subtree
181+
[reflection, subtree]
182+
else
183+
next
184+
end
185+
else
186+
reflection
187+
end
188+
189+
(h[key] ||= []) << record
190+
end
191+
h
192+
end
193+
end
194+
end
195+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright 2025 Roy Liu
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
6+
# use this file except in compliance with the License. You may obtain a copy of
7+
# the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations under
15+
# the License.
16+
17+
require "scalient/active_record/preloader_branch_monkey_patch"
18+
19+
module ActiveRecord
20+
module Associations
21+
class Preloader
22+
class Branch
23+
::Scalient::ActiveRecord::PreloaderBranchMonkeyPatch.apply(self)
24+
end
25+
end
26+
end
27+
end

0 commit comments

Comments
 (0)