activerecord-minimizer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,470 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module ArMinimizer
6
+ # Source-to-source compiler. Replaces calls to attribute pattern methods
7
+ # (anything in the Index: name?, name_changed?, saved_change_to_name?, ...)
8
+ # with the proxy ActiveRecord itself would call, guarded so that any other
9
+ # receiver gets the original call:
10
+ #
11
+ # user.name_changed?
12
+ # # becomes
13
+ # (::User.equal?(user.class) || ::Post.equal?(user.class) ? user.attribute_changed?(-"name") : user.name_changed?)
14
+ #
15
+ # listing the models that have the attribute (when there are more than
16
+ # MAX_CLASS_GUARD: `::ActiveRecord::Base === user && user.class._has_attribute?("name")`).
17
+ #
18
+ # Inside an instance method of a model that has the attribute, self is
19
+ # known, so the guard goes away:
20
+ #
21
+ # attribute_changed?(-"name")
22
+ #
23
+ # The guard checks the class, not the instance: generated methods exist for
24
+ # every attribute of the class, loaded by this query or not. Line numbers
25
+ # don't change: each replacement stays on its line, and the marker comment
26
+ # is appended at the end of the file.
27
+ class Rewriter
28
+ MARKER = "# ar_minimizer v#{VERSION} (rewritten)"
29
+ GENERATED_LINE = /^# ar_minimizer v\S+ \(rewritten\)$/
30
+ MAX_CLASS_GUARD = 4
31
+
32
+ Result = Data.define(:source, :sites, :skipped)
33
+
34
+ # marker: false for source that is compiled, not written back (templates).
35
+ # guards: also compile calls whose receiver class isn't known, behind a
36
+ # runtime class check (off by default: each such site costs code for a
37
+ # small gain, and a miss runs the check and the original call).
38
+ def initialize(index:, path: "(source)", marker: true, guards: false)
39
+ @index = index
40
+ @path = path
41
+ @marker = marker
42
+ @guards = guards
43
+ end
44
+
45
+ def rewrite(source)
46
+ return Result.new(source, 0, []) if source.match?(GENERATED_LINE)
47
+
48
+ parse = Prism.parse(source)
49
+ return Result.new(source, 0, ["#{@path}: does not parse, left untouched"]) unless parse.success?
50
+
51
+ @source = source
52
+ @sites = 0
53
+ @temps = 0
54
+ @skipped = []
55
+ @nesting = []
56
+ @nesting_kinds = []
57
+ @self_kinds = [:unknown]
58
+ @scopes = [scan_locals(parse.value.statements)]
59
+ @block_types = nil
60
+ body = splice(parse.value, 0, source.bytesize)
61
+ return Result.new(source, 0, @skipped) if @sites.zero?
62
+
63
+ rewritten = @marker ? append_marker(body, parse) : body
64
+ # Never hand back code that would break the app on boot.
65
+ unless Prism.parse(rewritten).success?
66
+ return Result.new(source, 0, @skipped + ["#{@path}: rewritten source does not parse, left untouched"])
67
+ end
68
+
69
+ Result.new(rewritten, @sites, @skipped)
70
+ end
71
+
72
+ private
73
+
74
+ # --- traversal -----------------------------------------------------------
75
+
76
+ # The node's source with every compilable call inside it replaced.
77
+ def render(node)
78
+ case node
79
+ when Prism::DefinedNode
80
+ # defined?(user.name?) is "method", defined?((... ? ... : ...)) is "expression".
81
+ text(node)
82
+ when Prism::ClassNode, Prism::ModuleNode
83
+ @nesting.push(constant_name(node.constant_path))
84
+ @nesting_kinds.push(node.is_a?(Prism::ClassNode) ? :class : :module)
85
+ scoped(scan_locals(node.body)) { within(:unknown) { splice(node) } }.tap { @nesting.pop; @nesting_kinds.pop }
86
+ when Prism::SingletonClassNode
87
+ scoped(scan_locals(node.body)) { within(:unknown) { splice(node) } }
88
+ when Prism::DefNode
89
+ scoped(scan_locals(node.body, untyped: parameter_names(node.parameters))) do
90
+ within(node.receiver ? :unknown : :instance) { splice(node) }
91
+ end
92
+ when Prism::BlockNode, Prism::LambdaNode
93
+ typed, @block_types = @block_types, nil
94
+ params = parameter_names(node.parameters.respond_to?(:parameters) ? node.parameters.parameters : nil)
95
+ # instance_exec/instance_eval DSLs can change self inside a block.
96
+ typed ||= {}
97
+ scoped(scan_locals(node.body, untyped: params - typed.keys, typed: typed)) { within(:unknown) { splice(node) } }
98
+ when Prism::CallNode
99
+ if (entry = compilable(node))
100
+ compile(node, entry)
101
+ else
102
+ @block_types = iteration_types(node) if node.block.is_a?(Prism::BlockNode)
103
+ splice(node)
104
+ end
105
+ else
106
+ splice(node)
107
+ end
108
+ end
109
+
110
+ def splice(node, from = node.location.start_offset, to = node.location.end_offset)
111
+ out = +""
112
+ cursor = from
113
+ node.compact_child_nodes.sort_by { it.location.start_offset }.each do |child|
114
+ start = child.location.start_offset
115
+ stop = child.location.end_offset
116
+ # Heredoc bodies live outside their node's range; leave them as they are.
117
+ next if start < cursor || stop > to
118
+
119
+ out << @source.byteslice(cursor, start - cursor) << render(child)
120
+ cursor = stop
121
+ end
122
+ out << @source.byteslice(cursor, to - cursor)
123
+ end
124
+
125
+ def within(kind)
126
+ @self_kinds.push(kind)
127
+ yield
128
+ ensure
129
+ @self_kinds.pop
130
+ end
131
+
132
+ def scoped(locals)
133
+ @scopes.push(locals)
134
+ yield
135
+ ensure
136
+ @scopes.pop
137
+ end
138
+
139
+ def text(node) = @source.byteslice(node.location.start_offset, node.location.length)
140
+
141
+ # --- matching ------------------------------------------------------------
142
+
143
+ def compilable(node)
144
+ entry = @index[node.name]
145
+ return unless entry
146
+ return if node.block # a block (or &blk) changes what the call means
147
+ return if node.arguments && !entry.args
148
+ return if node.receiver && !node.call_operator # `Foo::bar` style
149
+ return if heredoc?(node)
150
+
151
+ entry
152
+ end
153
+
154
+ def heredoc?(node)
155
+ return true if node.respond_to?(:opening_loc) && node.opening_loc && node.opening_loc.slice.start_with?("<<")
156
+
157
+ node.compact_child_nodes.any? { heredoc?(it) }
158
+ end
159
+
160
+ # --- code generation -----------------------------------------------------
161
+
162
+ def compile(node, entry)
163
+ @sites += 1
164
+ args = node.arguments && splice(node.arguments)
165
+ # -"name": a deduplicated frozen string, no allocation per call even
166
+ # without the frozen_string_literal comment (generated methods don't allocate either).
167
+ proxy_args = ["-#{entry.attr.inspect}", args].compact.join(", ")
168
+ original = "#{node.name}#{"(#{args})" if args}"
169
+
170
+ receiver = node.receiver
171
+ if receiver.nil? || receiver.is_a?(Prism::SelfNode)
172
+ dot = receiver ? "self." : ""
173
+ # Private proxies are callable on self, implicit or explicit.
174
+ call = query_on("self", entry) || "#{dot}#{entry.public_proxy || entry.proxy}(#{proxy_args})"
175
+ case self_type(entry)
176
+ when :known then return call # self is a record of a model with the attribute: no guard
177
+ when :not_a_model then return as_written(node) # never the attribute method
178
+ end
179
+ return as_written(node) unless @guards
180
+
181
+ return "(#{guard(entry, "self", "self")} ? #{call} : #{dot}#{original})"
182
+ end
183
+
184
+ type = expr_type(receiver)
185
+ if type&.kind == :record
186
+ case coverage(entry, with_descendants(type.model))
187
+ when :known then return typed_call(node, entry, type, proxy_args, original)
188
+ when :not_a_model then return as_written(node) # a record of a model without it: something else
189
+ end
190
+ end
191
+ return as_written(node) unless @guards
192
+
193
+ first, again = receiver_refs(receiver)
194
+ fast = fast_call(again, entry, proxy_args)
195
+ body = "#{guard(entry, first, again)} ? #{fast} : #{again}.#{original}"
196
+ if node.safe_navigation?
197
+ "(nil == #{first} ? nil : #{body.sub(first, again)})"
198
+ else
199
+ "(#{body})"
200
+ end
201
+ end
202
+
203
+ def fast_call(ref, entry, proxy_args)
204
+ query_on(ref, entry) ||
205
+ (entry.public_proxy ? "#{ref}.#{entry.public_proxy}(#{proxy_args})" : "#{ref}.__send__(:#{entry.proxy}, #{proxy_args})")
206
+ end
207
+
208
+ # The call as written (nested rewrites inside it still apply).
209
+ def as_written(node)
210
+ @sites -= 1
211
+ splice(node)
212
+ end
213
+
214
+ # A receiver of known model: no class check. Never nil: the call alone.
215
+ # Maybe nil: nil takes the original call, which raises (or, with &.,
216
+ # returns nil) exactly as before.
217
+ def typed_call(node, entry, type, proxy_args, original)
218
+ first, again = receiver_refs(node.receiver)
219
+ fast = fast_call(again, entry, proxy_args)
220
+ if type.nilable || node.safe_navigation?
221
+ fallback = node.safe_navigation? ? "nil" : "#{again}.#{original}"
222
+ "(nil == #{first} ? #{fallback} : #{fast})"
223
+ elsif first == again
224
+ fast
225
+ else
226
+ "(#{first}; #{fast})"
227
+ end
228
+ end
229
+
230
+ # name? straight from the reader, when the column type allows (Index::QUERY).
231
+ # Parenthesized: `age?.to_s` and `!age?` must still apply to the whole thing.
232
+ def query_on(receiver, entry)
233
+ reader = "#{receiver}.#{entry.attr}"
234
+ case entry.query
235
+ when :truthy then "(!!#{reader})"
236
+ when :numeric then "((#{reader} || 0) != 0)"
237
+ when :string then "(!#{reader}.blank?)"
238
+ end
239
+ end
240
+
241
+ # Few models with the attribute: compare classes (pointer checks, inline
242
+ # cached constants). Subclasses and models the build didn't see take the
243
+ # original call. Many models: ask the class.
244
+ def guard(entry, first, again)
245
+ if entry.classes.size.between?(1, MAX_CLASS_GUARD)
246
+ entry.classes.each_with_index.map { |name, i| "::#{name}.equal?(#{i.zero? ? first : again}.class)" }.join(" || ")
247
+ else
248
+ "::ActiveRecord::Base === #{first} && #{again}.class._has_attribute?(#{entry.attr.inspect})"
249
+ end
250
+ end
251
+
252
+ # How to refer to the receiver the first time and afterwards. Plain
253
+ # variables are repeated; anything else is evaluated once into a temp.
254
+ def receiver_refs(receiver)
255
+ case receiver
256
+ when Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode
257
+ [text(receiver), text(receiver)]
258
+ else
259
+ temp = "__arm#{@temps += 1}"
260
+ ["(#{temp} = #{render(receiver)})", temp]
261
+ end
262
+ end
263
+
264
+ # What self is in this instance method, from the class or module around it:
265
+ # :known a model with the attribute (and its subclasses), or an app
266
+ # concern whose including models all have it: no guard;
267
+ # :not_a_model a class that isn't a model: the call is never the
268
+ # attribute method, so it stays as written;
269
+ # nil can't tell (modules not known to be concerns,
270
+ # `class Foo::Bar` inside a namespace): guard.
271
+ # From the index alone, without loading constants: the Loader runs while
272
+ # files are being required, models included.
273
+ def self_type(entry)
274
+ return unless @self_kinds.last == :instance
275
+ return if @nesting.empty? || @nesting.any?(&:nil?)
276
+ return if @nesting.size > 1 && @nesting.any? { it.include?("::") }
277
+
278
+ name = @nesting.join("::").delete_prefix("::")
279
+ if model?(name) || entry.classes.include?(name)
280
+ coverage(entry, with_descendants(name))
281
+ elsif app_module?(name)
282
+ includers = @index.includers_of(name)
283
+ coverage(entry, includers) if includers.any?
284
+ elsif @nesting_kinds.last == :class && @index.respond_to?(:model?) # only a full index knows every model
285
+ :not_a_model
286
+ end
287
+ end
288
+
289
+ # :known when every possible class has the attribute method, :not_a_model
290
+ # when none does (the call is something else: an enum predicate, a
291
+ # hand-written method), nil when some do.
292
+ def coverage(entry, classes)
293
+ covered = classes.count { entry.classes.include?(it) }
294
+ if covered == classes.size then :known
295
+ elsif covered.zero? then :not_a_model
296
+ end
297
+ end
298
+
299
+ # The index may be a plain Hash (tests): no model knowledge then.
300
+ def model?(name) = @index.respond_to?(:model?) && @index.model?(name)
301
+ def app_module?(name) = @index.respond_to?(:app_module?) && @index.app_module?(name)
302
+ def with_descendants(name) = @index.respond_to?(:with_descendants) ? @index.with_descendants(name) : [name]
303
+ def covers?(entry, classes) = classes.all? { entry.classes.include?(it) }
304
+
305
+ # --- types ------------------------------------------------------------------
306
+
307
+ # What an expression is, when provable from the source and the index:
308
+ # a model's class, a relation of its records, or one record (maybe nil).
309
+ Type = Data.define(:model, :nilable, :kind)
310
+
311
+ # Calls on a model or relation that return one of its records, never nil.
312
+ RECORD_METHODS = %i[find find_by! first! last! take! sole find_sole_by].freeze
313
+ # ... or a record or nil.
314
+ MAYBE_RECORD_METHODS = %i[find_by first last take].freeze
315
+ # Relation methods whose result is still a relation of the same model
316
+ # (friendly: friendly_id's finder relation).
317
+ RELATION_METHODS = %i[all where not order reorder includes preload eager_load joins left_joins left_outer_joins
318
+ limit offset distinct readonly references unscope rewhere none friendly].freeze
319
+ # Iterations whose block gets the relation's records as first parameter.
320
+ ITERATIONS = %i[each find_each map flat_map filter_map each_with_index each_with_object select reject sort_by
321
+ group_by index_by partition min_by max_by sum find detect any? all? none? count].freeze
322
+
323
+ def expr_type(node)
324
+ case node
325
+ when Prism::LocalVariableReadNode then local_type(node)
326
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
327
+ (model = model_constant(node)) && Type.new(model, false, :class)
328
+ when Prism::ParenthesesNode
329
+ body = node.body
330
+ expr_type(body.body.last) if body.is_a?(Prism::StatementsNode) && body.body.size == 1
331
+ when Prism::SelfNode then self_record_type
332
+ when Prism::CallNode
333
+ return if node.block || node.safe_navigation?
334
+
335
+ receiver_type = node.receiver ? expr_type(node.receiver) : self_record_type
336
+ receiver_type && call_type(receiver_type, node)
337
+ end
338
+ end
339
+
340
+ def call_type(receiver, node)
341
+ args = node.arguments&.arguments || []
342
+ model = receiver.model
343
+ case receiver.kind
344
+ when :class, :relation
345
+ if RELATION_METHODS.include?(node.name) || scope?(model, node.name)
346
+ Type.new(model, false, :relation)
347
+ elsif RECORD_METHODS.include?(node.name)
348
+ Type.new(model, false, :record) if record_arguments?(node.name, args)
349
+ elsif %i[new create create!].include?(node.name) && receiver.kind == :class
350
+ # an Array of attributes would return an Array
351
+ Type.new(model, false, :record) if args.empty? || (args.size == 1 && (args[0].is_a?(Prism::KeywordHashNode) || args[0].is_a?(Prism::HashNode)))
352
+ elsif MAYBE_RECORD_METHODS.include?(node.name)
353
+ Type.new(model, true, :record) if node.name == :find_by || args.empty? # first(2) is an Array
354
+ end
355
+ when :record
356
+ return if receiver.nilable || !args.empty?
357
+
358
+ target, many = association(model, node.name)
359
+ return unless target
360
+
361
+ many == "many" ? Type.new(target, false, :relation) : Type.new(target, true, :record)
362
+ end
363
+ end
364
+
365
+ def record_arguments?(name, args)
366
+ case name
367
+ when :find # find([1, 2]) and find(1, 2) return Arrays
368
+ args.size == 1 && [Prism::ArrayNode, Prism::SplatNode, Prism::RangeNode].none? { args[0].is_a?(it) }
369
+ when :first!, :last!, :take! then args.empty?
370
+ else true
371
+ end
372
+ end
373
+
374
+ # self inside an instance method of one model (not a concern: several models).
375
+ def self_record_type
376
+ return unless @self_kinds.last == :instance && @nesting_kinds.last == :class
377
+ return if @nesting.empty? || @nesting.any?(&:nil?) || (@nesting.size > 1 && @nesting.any? { it.include?("::") })
378
+
379
+ name = @nesting.join("::").delete_prefix("::")
380
+ Type.new(name, false, :record) if model?(name)
381
+ end
382
+
383
+ def scope?(model, name) = @index.respond_to?(:scope?) && @index.scope?(model, name)
384
+ def association(model, name) = @index.respond_to?(:association) ? @index.association(model, name) : nil
385
+
386
+ def local_type(node)
387
+ scope = @scopes[-1 - node.depth]
388
+ scope && scope[node.name]
389
+ end
390
+
391
+ # { name => Type } for this scope's locals whose every assignment has
392
+ # the same provable type; untyped: names that start unknown
393
+ # (parameters); typed: block parameters known from the iteration.
394
+ def scan_locals(body, untyped: [], typed: {})
395
+ writes = Hash.new { |h, k| h[k] = [] }
396
+ untyped.each { writes[it] << nil }
397
+ typed.each { |name, type| writes[name] << type }
398
+ collect_writes(body, 0, writes) if body
399
+ writes.filter_map { |name, types| [name, types.first] if types.first&.kind == :record && types.uniq.size == 1 }.to_h
400
+ end
401
+
402
+ def collect_writes(node, depth, writes)
403
+ case node
404
+ when Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode
405
+ return # their own scopes
406
+ when Prism::BlockNode, Prism::LambdaNode
407
+ depth += 1
408
+ when Prism::LocalVariableWriteNode
409
+ writes[node.name] << expr_type(node.value) if node.depth == depth
410
+ when Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode,
411
+ Prism::LocalVariableTargetNode
412
+ writes[node.name] << nil if node.depth == depth
413
+ end
414
+ node.compact_child_nodes.each { collect_writes(it, depth, writes) }
415
+ end
416
+
417
+ def parameter_names(params)
418
+ return [] unless params
419
+
420
+ [*params.requireds, *params.optionals, params.rest, *params.posts, *params.keywords, params.keyword_rest, params.block]
421
+ .compact.filter_map { it.respond_to?(:name) ? it.name : nil }
422
+ end
423
+
424
+ # { block parameter => record Type } for <relation>.each { |record| }.
425
+ def iteration_types(node)
426
+ return unless ITERATIONS.include?(node.name)
427
+
428
+ params = node.block.parameters
429
+ first = params.respond_to?(:parameters) && params.parameters&.requireds&.first
430
+ return unless first.is_a?(Prism::RequiredParameterNode)
431
+
432
+ type = node.receiver && expr_type(node.receiver)
433
+ { first.name => Type.new(type.model, false, :record) } if type && %i[class relation].include?(type.kind)
434
+ end
435
+
436
+ # A model, by its constant as written. Unqualified names inside a namespace
437
+ # could mean Namespace::Name, so they count only when no such model or
438
+ # concern exists.
439
+ def model_constant(node)
440
+ return unless (name = constant_name(node))
441
+
442
+ name = name.delete_prefix("::")
443
+ return unless model?(name)
444
+ unless node.is_a?(Prism::ConstantPathNode) && node.parent.nil? # ::Name is unambiguous
445
+ prefixes = @nesting.compact
446
+ return if (1..prefixes.size).any? { |n| (other = "#{prefixes.first(n).join("::")}::#{name}") != name && (model?(other) || app_module?(other)) }
447
+ end
448
+ name
449
+ end
450
+
451
+ def constant_name(path)
452
+ case path
453
+ when Prism::ConstantReadNode then path.name.to_s
454
+ when Prism::ConstantPathNode
455
+ parent = path.parent ? constant_name(path.parent) : ""
456
+ parent && "#{parent}::#{path.name}"
457
+ end
458
+ end
459
+
460
+ def append_marker(body, parse)
461
+ if (data = parse.data_loc)
462
+ head = body.byteslice(0, body.bytesize - (@source.bytesize - data.start_offset))
463
+ tail = @source.byteslice(data.start_offset, @source.bytesize - data.start_offset)
464
+ "#{head}#{"\n" unless head.end_with?("\n")}#{MARKER}\n#{tail}"
465
+ else
466
+ "#{body}#{"\n" unless body.end_with?("\n")}#{MARKER}\n"
467
+ end
468
+ end
469
+ end
470
+ end