forked from ManageIQ/manageiq
-
Notifications
You must be signed in to change notification settings - Fork 0
/
descendant_loader.rb
277 lines (242 loc) · 7.77 KB
/
descendant_loader.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# When using STI with class hierarchies that are more than two levels
# deep, ActiveRecord relies on +descendants+ to retrieve all the
# expected records when querying from an "in-between" class. But the
# +descendants+ supplied by ActiveSupport can only know about classes
# that have been loaded.
#
# To address this, we must pre-parse all the class definitions in the
# application, noting inheritances. Then we wrap the built-in
# +descendants+ method, to explicitly load all previously-identified
# child classes, before ActiveSupport does its thing.
#
# While we don't do anything specifically for it, +subclasses+ will also
# work correctly, because it's implemented in terms of +descendants+.
#
#
# Example:
# Given a class hierarchy of:
#
# class Aaa < ActiveRecord::Base; end
# class Bbb < Aaa; end
# class Ccc < Bbb; end
#
# Without this change, the following queries will be generated:
#
# Aaa.count
# # SELECT COUNT(*) FROM "aaas"
# Bbb.count
# # SELECT COUNT(*) FROM "aaas" WHERE "aaas"."type" IN ('Bbb')
# Ccc.count
# # SELECT COUNT(*) FROM "aaas" WHERE "aaas"."type" IN ('Ccc')
#
# The `Bbb` query is incorrect since the application doesn't know
# `Ccc` exists at the time. (Running `Bbb.count` again after `Ccc` is
# loaded will give the correct result.)
#
# With DescendantLoader, the correct queries will be generated:
#
# Aaa.count
# # SELECT COUNT(*) FROM "aaas"
# Bbb.count
# # SELECT COUNT(*) FROM "aaas" WHERE "aaas"."type" IN ('Bbb', 'Ccc')
# Ccc.count
# # SELECT COUNT(*) FROM "aaas" WHERE "aaas"."type" IN ('Ccc')
#
# When Active Record calls `Bbb.descendants` to construct the `type`
# condition, `Ccc` is automatically loaded.
#
class DescendantLoader
CACHE_VERSION = 2
def self.instance
@instance ||= new
end
# Debug/tracing method only
def self.status(io = $stdout)
require 'miq-process'
io.puts(MiqProcess.processInfo[:memory_usage] / 1024)
l = ObjectSpace.each_object(Class).select { |c| c < ActiveRecord::Base }
io.puts l.map(&:name).sort.join(" ")
io.puts l.size
io.puts
end
# Extract class definitions (namely: a list of which scopes it might
# be defined in [depending on runtime details], the name of the class,
# and the name of its superclass), given a path to a ruby script file.
module Parser
autoload :Prism, 'prism'
def classes_in(filename)
begin
parsed = Prism::Translation::RubyParser.parse_file(filename)
rescue => e
warn "\nError parsing classes in #{filename}:\n#{e.class.name}: #{e}\n\n"
raise
end
classes = collect_classes(parsed)
classes.map do |(scopes, (_, name, sklass))|
next unless sklass
scope_names = scopes.map { |s| flatten_name(s) }
search_combos = name_combinations(scope_names)
# We're assuming this is the original class definition, so it
# will definitely be defined inside the innermost containining
# scope. We're just not sure how that scope plays out relative
# to its parents.
if (container_name = scope_names.pop)
define_combos = scoped_name(container_name, name_combinations(scope_names))
else
define_combos = search_combos.dup
end
[search_combos, define_combos, flatten_name(name), flatten_name(sklass)]
end.compact
end
def collect_classes(node, parents = [])
type, *rest = node
case type
when :class
name, superklass, *body = rest
[[parents, [type, name, superklass]]] +
body.flat_map { |n| collect_classes(n, parents + [name]) }
when :module
name, *body = rest
body.flat_map { |n| collect_classes(n, parents + [name]) }
when :block
rest.flat_map { |n| collect_classes(n, parents) }
when :cdecl
name, superklass = rest
if [:const, :colon2, :colon3].include?(superklass.first)
[[parents, [type, name, superklass]]]
else
[]
end
else
[]
end
end
def flatten_name(node)
return node.to_s if node.kind_of?(Symbol)
type, *rest = node
case type
when :const
rest.first.to_s
when :colon2
left, right = rest
left = flatten_name(left)
"#{left}::#{right}" if left
when :colon3
"::#{rest.first}"
else
raise "Unknown name node: #{type}"
end
end
def name_combinations(names)
combos = [[]]
names.size.times do |n|
combos += names.combination(n + 1).to_a
end
combos.each do |combo|
if (i = combo.rindex { |s| s =~ /^::/ })
combo.slice!(0, i)
combo[0] = combo[0].sub(/^::/, '')
end
end
combos.map { |c| c.join('::') }.uniq.reverse
end
end
# RubyParser is slow, so wrap it in a simple mtime-based cache.
module Cache
def cache_path
Rails.root.join('tmp/cache/sti_loader.yml')
end
def load_cache
return unless cache_path.exist?
data = File.open(cache_path, "r") do |f|
f.flock(File::LOCK_SH)
YAML.load(f.read)
end
return unless data && data.kind_of?(Hash) && data['@version'].to_i == CACHE_VERSION
data
rescue
nil
end
def cache
@cache ||= load_cache || {'@version' => CACHE_VERSION}
end
def save_cache!
return unless @cache_dirty
# Don't write sti_loader.yml in production, this shouldn't change from what is in the RPM
if Rails.env.production?
warn "\nSTI cache is out of date in production, check that source files haven't been modified"
else
cache_path.parent.mkpath
cache_path.open('w') do |f|
f.flock(File::LOCK_EX)
YAML.dump(cache, f)
end
end
end
def classes_in(filename)
t = File.mtime(filename)
if (entry = cache[filename])
return entry[:parsed] if entry[:mtime] == t
end
super.tap do |data|
@cache_dirty = true
cache[filename] = {:mtime => t, :parsed => data}
end
end
end
module Mapper
def descendants_paths
@descendants_paths ||= [Rails.root.join("app/models")]
end
def class_inheritance_relationships
@class_inheritance_relationships ||= begin
children = Hash.new { |h, k| h[k] = [] }
Dir.glob(descendants_paths.map { |path| Pathname.new(path).join('**/*.rb') }) do |file|
classes_in(file).each do |search_scopes, define_scopes, name, sklass|
possible_names = scoped_name(name, define_scopes)
possible_superklasses = scoped_name(sklass, search_scopes)
possible_superklasses.each do |possible_superklass|
children[possible_superklass].concat(possible_names)
end
end
end
children
end
end
def clear_class_inheritance_relationships
@class_inheritance_relationships = nil
end
end
include Parser
include Cache
include Mapper
def discovered_parent_child_classes
@discovered_parent_child_classes ||= class_inheritance_relationships.select { |_k, names| names.length > 0 }
end
def load_subclasses(parent)
names_to_load = class_inheritance_relationships[parent.to_s].dup
while (name = names_to_load.shift)
if (_klass = name.safe_constantize) # this triggers the load
names_to_load.concat(class_inheritance_relationships[name])
end
end
end
def scoped_name(name, scopes)
if name =~ /^::(.*)/
name = [$1]
else
scopes.map do |scope|
scope.empty? ? name : "#{scope}::#{name}"
end
end
end
module AsDependenciesClearWithLoader
def clear
DescendantLoader.instance.clear_class_inheritance_relationships
super
end
end
end
at_exit do
DescendantLoader.instance.save_cache!
end