activerecord-precompiled 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,917 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "digest"
5
+
6
+ module ActiveRecordPrecompiled
7
+ # Source-to-source compiler. Finds ActiveRecord query chains rooted at a
8
+ # model constant, asks ActiveRecord (via Capture) for the SQL each one
9
+ # produces, and replaces the chain with a call to a precompiled Site while
10
+ # keeping the original chain as the fallback block:
11
+ #
12
+ # User.where(name: n).first
13
+ # # becomes
14
+ # ::ActiveRecordPrecompiled::Sites::S1a2b3c4d5e_0.call(n) { |__xs0| User.where(name: __xs0).first }
15
+ #
16
+ # Site definitions go into a single `BEGIN { }` line at the end of the file
17
+ # (before `__END__`), so every existing line keeps its line number.
18
+ class Rewriter
19
+ MARKER = "# activerecord-precompiled"
20
+ # The generated definitions line; its presence means the file was already rewritten.
21
+ GENERATED_LINE = /^BEGIN \{ ::ActiveRecordPrecompiled::Site\.define\(.*\} # activerecord-precompiled v/
22
+
23
+ BUILDERS = %i[all where order group limit offset includes preload].freeze
24
+ TERMINATORS = {
25
+ to_a: :records, first: :one, take: :one, find_by: :one, last: :last,
26
+ first!: :one!, take!: :one!, find: :one!, find_by!: :one!,
27
+ count: :count, exists?: :exists, pluck: :pluck,
28
+ sum: :calculate, maximum: :calculate, minimum: :calculate, average: :calculate,
29
+ }.freeze
30
+ # Enumerable methods called on a relation just load it; we rewrite the
31
+ # relation itself and leave the method (and its block) in place.
32
+ ENUMERABLE = %i[each map flat_map filter_map each_with_index each_with_object index_by group_by].freeze
33
+
34
+ # Calls whose result is a collection: passing one to `where` means `IN (...)`,
35
+ # which the precompiled `= ?` can never serve.
36
+ COLLECTION_METHODS = %i[map flat_map filter_map collect compact uniq pluck ids to_a keys values select reject].freeze
37
+
38
+ Result = Data.define(:source, :sites, :skipped)
39
+ Skip = Class.new(StandardError)
40
+
41
+ def initialize(path:)
42
+ @path = path
43
+ @prefix = "S#{Digest::SHA256.hexdigest(path.to_s)[0, 10]}"
44
+ end
45
+
46
+ def rewrite(source)
47
+ return Result.new(source, 0, []) if source.match?(GENERATED_LINE)
48
+
49
+ parse = Prism.parse(source)
50
+ return Result.new(source, 0, ["#{@path}: does not parse, left untouched"]) unless parse.success?
51
+
52
+ @source = source
53
+ @definitions = []
54
+ @sites = 0 # call sites; a friendly_id find defines several Sites for one
55
+ @skipped = []
56
+ @nesting = []
57
+ body = rewrite_range(parse.value, 0, source.bytesize)
58
+ return Result.new(source, 0, @skipped) if @definitions.empty?
59
+
60
+ rewritten = append_definitions(body, parse)
61
+ # Last line of defence: never hand back code that would break the app on boot.
62
+ unless Prism.parse(rewritten).success?
63
+ return Result.new(source, 0, @skipped + ["#{@path}: rewritten source does not parse, left untouched"])
64
+ end
65
+
66
+ Result.new(rewritten, @sites, @skipped)
67
+ end
68
+
69
+ private
70
+
71
+ # --- traversal -----------------------------------------------------------
72
+
73
+ def rewrite_range(node, from, to)
74
+ edits = []
75
+ collect(node, edits)
76
+ out = +""
77
+ cursor = from
78
+ edits.sort_by(&:first).each do |start, stop, text|
79
+ out << @source.byteslice(cursor, start - cursor) << text
80
+ cursor = stop
81
+ end
82
+ out << @source.byteslice(cursor, to - cursor)
83
+ end
84
+
85
+ def collect(node, edits)
86
+ return unless node
87
+
88
+ if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
89
+ collect(node.superclass, edits) if node.is_a?(Prism::ClassNode)
90
+ # Track the lexical scope so bare constants resolve the way Ruby resolves them.
91
+ @nesting.push(namespace_for(node))
92
+ collect(node.body, edits)
93
+ @nesting.pop
94
+ return
95
+ end
96
+
97
+ if node.is_a?(Prism::CallNode)
98
+ if (kind = TERMINATORS[node.name]) && (edit = try_site(node, kind, terminator: true))
99
+ return edits << edit
100
+ end
101
+
102
+ if ENUMERABLE.include?(node.name) && node.block && node.receiver.is_a?(Prism::CallNode) &&
103
+ !TERMINATORS.key?(node.receiver.name) && (edit = try_site(node.receiver, :records, terminator: false))
104
+ edits << edit
105
+ collect(node.arguments, edits)
106
+ collect(node.block, edits)
107
+ return
108
+ end
109
+ end
110
+
111
+ node.compact_child_nodes.each { collect(it, edits) }
112
+ end
113
+
114
+ # --- matching ------------------------------------------------------------
115
+
116
+ def try_site(node, kind, terminator:)
117
+ return if node.block # a block on the terminator changes its meaning
118
+
119
+ calls = []
120
+ cur = node
121
+ while cur.is_a?(Prism::CallNode) && cur.receiver
122
+ calls.unshift(cur)
123
+ cur = cur.receiver
124
+ end
125
+ unless cur.is_a?(Prism::ConstantReadNode) || cur.is_a?(Prism::ConstantPathNode)
126
+ return try_association_site(cur, calls, kind, terminator, node)
127
+ end
128
+
129
+ terminator = terminator ? calls.pop : nil
130
+ # Builders, or scopes (checked against the model in build_site).
131
+ return unless calls.all? { BUILDERS.include?(it.name) || !it.name.end_with?("!", "?", "=") }
132
+ return if (calls + [terminator]).compact.any? { it.safe_navigation? || it.block }
133
+
134
+ build_site(cur, calls, terminator, kind, node)
135
+ rescue Skip => e
136
+ @skipped << "#{@path}:#{node.location.start_line}: #{e.message}"
137
+ nil
138
+ rescue StandardError => e
139
+ # ActiveRecord rejected the chain at build time; leave it alone, keep building.
140
+ @skipped << "#{@path}:#{node.location.start_line}: #{e.class}: #{e.message.lines.first&.strip}"
141
+ nil
142
+ end
143
+
144
+ def build_site(root, calls, terminator, kind, range_node)
145
+ model = resolve_model(root)
146
+ if calls.map(&:name) == [:friendly] && terminator&.name == :find
147
+ return build_friendly_site(model, calls.first, terminator, range_node)
148
+ end
149
+
150
+ validate_terminator!(terminator)
151
+ args = [] # [node, ...] runtime arguments, by sentinel index
152
+ name = :"#{@prefix}_#{@definitions.size}"
153
+ # A bare Model.find answers from a cached statement, not Relation#load.
154
+ lazy = !(calls.empty? && terminator&.name == :find)
155
+ @definitions << site_definition(name, model, -> { model }, calls, terminator, kind, args, lazy: lazy)
156
+ @sites += 1
157
+ [range_node.location.start_offset, range_node.location.end_offset, call_site(name, range_node, args)]
158
+ end
159
+
160
+ # The Site.define for a chain run on `base.call` (the model, or an
161
+ # association's CollectionProxy). `args` collects the runtime arguments;
162
+ # `key` is the probe standing for the association owner's key.
163
+ def site_definition(name, model, base, calls, terminator, kind, args, lazy: true, key: nil)
164
+ @list_args = {} # sentinel index => type, for condition values an Array may replace
165
+ @collection_args = {} # sentinel index => source, for arguments that look like collections
166
+ @id_args = [] # sentinel indices that must be an Integer or String id at runtime
167
+ @base = base
168
+ @key_probe = key
169
+ steps = where_chains(calls).map { |call| [call.name, ruby_args(call, model, args)] }
170
+ steps << (terminator ? [terminator.name, ruby_args(terminator, model, args)] : [:to_a, []])
171
+
172
+ relation, queries = capture_chain(base.call, steps)
173
+ raise Skip, "expected 1 query, ActiveRecord ran #{queries.size}" unless queries.size == 1
174
+
175
+ query = queries.first
176
+ group = group_columns(relation, kind)
177
+ calc = [terminator.name.to_s, steps.last[1][0].to_s] if kind == :calculate
178
+ kind = :"group_#{kind}" if group
179
+ binds = bind_specs(model, query, args.size, key: key)
180
+ lists = in_lists(model, steps, query, binds)
181
+ @collection_args.each do |index, source|
182
+ raise Skip, "argument is a collection or range: #{source}" unless lists.any? { binds[it][1] == index }
183
+ end
184
+ preload = preload_values(relation, kind)
185
+ plans = preload && Preload.compile(model, preload)
186
+ "::ActiveRecordPrecompiled::Site.define(#{name.inspect}, model: #{model.name.inspect}, " \
187
+ "kind: #{kind.inspect}, sql: #{query.sql.inspect}, binds: #{binds.inspect}, schema: #{Site.schema_of(model).inspect}" \
188
+ "#{", lists: #{lists.inspect}" if lists.any?}#{", ids: #{@id_args.inspect}" if @id_args.any?}#{", group: #{group.inspect}" if group}#{", calc: #{calc.inspect}" if calc}" \
189
+ "#{", preload: #{preload.inspect}" if preload}#{", preload_plans: #{plans.inspect}" if plans&.any?}" \
190
+ "#{", lazy: false" unless lazy})"
191
+ end
192
+
193
+ # --- association roots ---------------------------------------------------------
194
+
195
+ OWNER_PROBES = { integer: -7_000_003, string: "__ar_precompiled_owner__" }.freeze
196
+
197
+ # `owner.posts.where(...).first`, `posts.count` (self's association):
198
+ # the chain after a collection association. The receiver can't be typed
199
+ # statically, so every model with a collection association of that name
200
+ # gets its own Site, and an AssocSite picks one by the owner's class.
201
+ def try_association_site(root, calls, kind, terminator, node)
202
+ # A bare `posts` is a call without a receiver: the bottom of the chain.
203
+ calls = [root] + calls if root.is_a?(Prism::CallNode) && root.receiver.nil?
204
+ terminator = terminator ? calls.pop : nil
205
+ at = calls.rindex { association_call?(it) } or return
206
+ assoc, rest = calls[at], calls[(at + 1)..]
207
+ return unless rest.all? { BUILDERS.include?(it.name) || !it.name.end_with?("!", "?", "=") }
208
+ return if (rest + [terminator]).compact.any? { it.safe_navigation? || it.block } || assoc.safe_navigation?
209
+
210
+ build_association_site(assoc, rest, terminator, kind, node)
211
+ end
212
+
213
+ def association_call?(call)
214
+ call.is_a?(Prism::CallNode) && call.arguments.nil? && call.block.nil? &&
215
+ !BUILDERS.include?(call.name) && !TERMINATORS.key?(call.name) && association_owners.key?(call.name)
216
+ end
217
+
218
+ # Association name => [[owner model, reflection], ...] for collection associations.
219
+ def association_owners
220
+ @association_owners ||= ActiveRecord::Base.descendants.each_with_object(Hash.new { |h, k| h[k] = [] }) do |model, index|
221
+ next if model.abstract_class? || model.name.nil? || !(Object.const_get(model.name) rescue nil).equal?(model)
222
+
223
+ model.reflect_on_all_associations.each do |reflection|
224
+ next unless reflection.collection?
225
+
226
+ reflection = model._reflect_on_association(reflection.name) # has_and_belongs_to_many as its through association
227
+ index[reflection.name] << [model, reflection]
228
+ end
229
+ end
230
+ end
231
+
232
+ def build_association_site(assoc, calls, terminator, kind, range_node)
233
+ validate_terminator!(terminator)
234
+ # to_a/each on the association itself loads it into the owner.
235
+ raise Skip, "#{assoc.name} loaded as a whole" if calls.empty? && kind == :records
236
+
237
+ candidates = association_owners[assoc.name]
238
+ if assoc.receiver.nil? && (owner = @nesting.last).is_a?(Class) && owner < ActiveRecord::Base
239
+ candidates = candidates.select { |model, _| model <= owner }
240
+ end
241
+ name = :"#{@prefix}_#{@definitions.size}"
242
+ owners = {}
243
+ definitions = []
244
+ reasons = []
245
+ arg_nodes = nil
246
+ candidates.each_with_index do |(owner, reflection), i|
247
+ model, key, probe = association_target(owner, reflection)
248
+ args = [assoc]
249
+ base = -> { owner.instantiate(key => probe).public_send(reflection.name) }
250
+ definitions << site_definition(:"#{name}_#{i}", model, base, calls, terminator, kind, args, key: probe)
251
+ raise Skip, "arguments differ between owners" if arg_nodes && arg_nodes.map(&:__id__) != args.map(&:__id__)
252
+
253
+ arg_nodes = args
254
+ owners[owner.name] = [:"#{name}_#{i}", key]
255
+ rescue Skip, StandardError => e
256
+ reasons << "#{owner.name}.#{reflection.name}: #{e.message.lines.first&.strip}"
257
+ end
258
+ raise Skip, "#{assoc.name}: #{reasons.uniq.first(2).join("; ")}" if owners.empty?
259
+
260
+ @definitions.concat(definitions)
261
+ @definitions << "::ActiveRecordPrecompiled::AssocSite.define(#{name.inspect}, association: #{assoc.name.inspect}, " \
262
+ "direct: #{calls.empty?}, owners: #{owners.inspect})"
263
+ @sites += 1
264
+ [range_node.location.start_offset, range_node.location.end_offset, call_site(name, range_node, arg_nodes)]
265
+ end
266
+
267
+ # [target model, owner key attribute, probe] for an association whose
268
+ # query is fixed apart from the owner's key.
269
+ def association_target(owner, reflection)
270
+ raise Skip, "disable_joins" if reflection.options[:disable_joins]
271
+
272
+ reflection.chain.each do |part|
273
+ raise Skip, "association scope is not static" if part.scope && !Preload.static_scope?(part)
274
+ end
275
+ key = Keys.join_foreign_key(reflection.chain.last)
276
+ raise Skip, "composite key" unless key.is_a?(String)
277
+
278
+ probe = OWNER_PROBES[owner.type_for_attribute(key).type] or raise Skip, "owner key type"
279
+ [check_model!(reflection.klass, reflection.klass.name), key, probe]
280
+ end
281
+
282
+ # Runs the chain on ActiveRecord with the queries captured: [relation, queries].
283
+ def capture_chain(base, steps)
284
+ relation = nil
285
+ model = base.is_a?(Class) ? base : base.model
286
+ queries = with_prepared_statements(model) do
287
+ Capture.queries do
288
+ relation = steps[0...-1].inject(base) do |rel, (name, values)|
289
+ next rel.where.not(*values).tap { ensure_activerecord_method!(rel, :where) } if name == :not
290
+
291
+ ensure_activerecord_method!(rel, name)
292
+ rel.public_send(name, *values)
293
+ end
294
+ name, values = steps.last
295
+ ensure_activerecord_method!(relation, name)
296
+ relation.public_send(name, *values)
297
+ end
298
+ end
299
+ [relation, queries.reject { it.sql.start_with?("PRAGMA") }]
300
+ end
301
+
302
+ # --- arrays ------------------------------------------------------------------
303
+
304
+ # `where(id: ids)` with an Array of two or more values is `id IN (?, ...)`
305
+ # (`NOT IN` for where.not); one value is the scalar SQL. For each
306
+ # condition argument whose `= ?` becomes exactly that, `[bind position,
307
+ # start, stop, not]`: the Site swaps `sql[start...stop]` for an IN list.
308
+ def in_lists(model, steps, query, binds)
309
+ @list_args.filter_map do |index, type|
310
+ at = binds.index { it[0] == :arg && it[1] == index }
311
+ at && in_list(model, steps, query, binds, index, type, at)
312
+ end
313
+ end
314
+
315
+ def in_list(model, steps, query, binds, index, type, at)
316
+ sql = query.sql
317
+ question = Site.placeholder_offset(sql, at) or return nil
318
+ negated = sql[question - 4, 5] == " != ?"
319
+ start = negated ? question - 4 : question - 3
320
+ return nil unless negated || sql[start, 4] == " = ?"
321
+
322
+ probe = list_probe(type) or return nil
323
+ expected = "#{sql[0...start]}#{negated ? " NOT IN" : " IN"} (?, ?)#{sql[(question + 1)..]}"
324
+ _, queries = begin
325
+ capture_chain(@base.call, substitute(steps, index, [probe, probe]))
326
+ rescue StandardError
327
+ return nil
328
+ end
329
+ return nil unless queries.size == 1 && queries[0].sql == expected
330
+
331
+ probe_binds = queries[0].binds
332
+ return nil unless probe_binds.size == binds.size + 1
333
+
334
+ others = probe_binds[0...at] + probe_binds[(at + 2)..]
335
+ same = others.zip(binds[0...at] + binds[(at + 1)..]).all? do |bind, spec|
336
+ raw = bind.value_before_type_cast
337
+ if spec[0] == :val
338
+ !raw.is_a?(Capture::Sentinel) && model.with_connection { Site.db_constant(it, bind.value_for_database) } == spec[1]
339
+ elsif spec[0] == :key
340
+ !raw.is_a?(Capture::Sentinel) && raw == @key_probe
341
+ else
342
+ raw.is_a?(Capture::Sentinel) && raw.index == spec[1]
343
+ end
344
+ end
345
+ same ? at : nil
346
+ end
347
+
348
+ # A value ActiveRecord binds as itself in an IN list of this type.
349
+ def list_probe(type)
350
+ if type.respond_to?(:mapping, true) && (label = type.send(:mapping).keys.first)
351
+ return label.to_s
352
+ end
353
+
354
+ case type.type
355
+ when :integer then -7_000_001
356
+ when :string, :text then "__ar_precompiled_probe__"
357
+ when :float then 1.5
358
+ when :decimal then BigDecimal("1.5")
359
+ when :date then Date.new(2000, 1, 1)
360
+ when :datetime then Time.utc(2000, 1, 1)
361
+ end
362
+ end
363
+
364
+ # `steps` with argument `index` replaced by `value`.
365
+ def substitute(value, index, replacement)
366
+ case value
367
+ when Capture::Sentinel then value.index == index ? replacement : value
368
+ when Array then value.map { substitute(it, index, replacement) }
369
+ when Hash then value.transform_values { substitute(it, index, replacement) }
370
+ else value
371
+ end
372
+ end
373
+
374
+ # --- friendly_id -----------------------------------------------------------
375
+
376
+ FRIENDLY_FINDERS = %w[FriendlyId::FinderMethods FriendlyId::History::FinderMethods].freeze
377
+
378
+ # `Model.friendly.find(x)`: one Site per query FriendlyId may run, tied
379
+ # together by a FriendlySite. Only for plain :slugged/:history setups,
380
+ # where those queries are fixed.
381
+ def build_friendly_site(model, friendly, find, range_node)
382
+ check_friendly!(model, friendly, find)
383
+ args = [find.arguments.arguments.first]
384
+ relation = model.friendly
385
+ query_field = model.friendly_id_config.query_field
386
+
387
+ slug_queries = with_prepared_statements(model) do
388
+ sentinel = Capture::Sentinel.for(model.type_for_attribute(query_field), 0)
389
+ Capture.queries { relation.send(:first_by_friendly_id, sentinel) }
390
+ end.reject { it.sql.start_with?("PRAGMA") }
391
+ by_id = with_prepared_statements(model) do
392
+ sentinel = Capture::Sentinel.for(model.type_for_attribute(model.primary_key), 0)
393
+ # What FinderMethods#find's `super` runs.
394
+ Capture.queries { ActiveRecord::Relation.instance_method(:find).bind_call(relation, sentinel) }
395
+ end.reject { it.sql.start_with?("PRAGMA") }
396
+ history = model.friendly_id_config.finder_methods.name == "FriendlyId::History::FinderMethods"
397
+ raise Skip, "friendly.find ran unexpected queries" unless by_id.size == 1 && slug_queries.size == (history ? 2 : 1)
398
+
399
+ by_slug, by_history = slug_queries
400
+
401
+ parts = { slug: [:one, by_slug.sql, bind_specs(model, by_slug, 1)],
402
+ id: [:one!, by_id.first.sql, bind_specs(model, by_id.first, 1)] }
403
+ parts[:history] = [:one, *inline_arg_to_bind(model, by_history, query_field)] if history
404
+
405
+ # Nothing is defined until every part compiled.
406
+ names = parts.transform_values { |(kind, sql, binds)| friendly_part(model, kind, sql, binds) }
407
+ name = :"#{@prefix}_#{@definitions.size}"
408
+ @definitions << "::ActiveRecordPrecompiled::FriendlySite.define(#{name.inspect}, " \
409
+ "#{names.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")})"
410
+ @sites += 1
411
+ [range_node.location.start_offset, range_node.location.end_offset, call_site(name, range_node, args)]
412
+ end
413
+
414
+ def check_friendly!(model, friendly, find)
415
+ nodes = find.arguments&.arguments || []
416
+ unless nodes.size == 1 && !hash_node?(nodes[0]) && !nodes[0].is_a?(Prism::SplatNode) && !nodes[0].is_a?(Prism::BlockArgumentNode)
417
+ raise Skip, "friendly.find needs exactly one id"
418
+ end
419
+ raise Skip, "friendly takes no arguments" if friendly.arguments
420
+ raise Skip, "heredoc argument" if heredoc?(nodes[0])
421
+ unless defined?(::FriendlyId::Base) && model.method(:friendly).owner == ::FriendlyId::Base
422
+ raise Skip, "friendly overrides ActiveRecord's (not friendly_id's own)"
423
+ end
424
+
425
+ config = model.friendly_id_config
426
+ raise Skip, "friendly_id :scoped finds within a scope" if config.uses?(:scoped)
427
+ raise Skip, "friendly_id finder is #{config.finder_methods}" unless FRIENDLY_FINDERS.include?(config.finder_methods.name)
428
+ raise Skip, "#{model.name} has a non-integer primary key" unless model.type_for_attribute(model.primary_key).type == :integer
429
+
430
+ relation = model.friendly
431
+ %i[find first_by_friendly_id parse_friendly_id potential_primary_key?].each do |method|
432
+ owner = relation.method(method).owner.name
433
+ raise Skip, "#{method} is overridden (#{owner})" unless FRIENDLY_FINDERS.include?(owner)
434
+ end
435
+ end
436
+
437
+ def friendly_part(model, kind, sql, binds)
438
+ name = :"#{@prefix}_#{@definitions.size}"
439
+ @definitions << "::ActiveRecordPrecompiled::Site.define(#{name.inspect}, model: #{model.name.inspect}, " \
440
+ "kind: #{kind.inspect}, sql: #{sql.inspect}, binds: #{binds.inspect}, schema: #{Site.schema_of(model).inspect})"
441
+ name
442
+ end
443
+
444
+ # friendly_id's history lookup compares with an Arel node, which puts the
445
+ # value into the SQL as a quoted literal. Make that literal a bind again.
446
+ def inline_arg_to_bind(model, query, attribute)
447
+ quoted = model.with_connection { it.quote("__ar_precompiled_arg_0__") }
448
+ raise Skip, "friendly_id history query doesn't contain the slug once" unless query.sql.scan(quoted).size == 1
449
+
450
+ before = query.sql[0, query.sql.index(quoted)]
451
+ position = before.gsub(/'(?:[^']|'')*'|"(?:[^"]|"")*"/, "").count("?")
452
+ sql = query.sql.sub(quoted, "?")
453
+ binds = bind_specs(model, Capture::Query.new(sql, query.binds), 0)
454
+ [sql, binds.insert(position, [:arg, 0, attribute])]
455
+ end
456
+
457
+ # `where.not(hash)` is two calls; the `where` without arguments only opens
458
+ # the chain, so drop it and let `not` stand for both.
459
+ def where_chains(calls)
460
+ calls.each_with_index.filter_map do |call, i|
461
+ if call.name == :where && call.arguments.nil? && !call.block
462
+ following = calls[i + 1]
463
+ raise Skip, "where.#{following&.name} is not supported" unless following&.name == :not
464
+
465
+ next
466
+ end
467
+ raise Skip, "not without where" if call.name == :not && !(i.positive? && calls[i - 1].name == :where && calls[i - 1].arguments.nil?)
468
+
469
+ call
470
+ end
471
+ end
472
+
473
+ # What Relation#load would hand to the Preloader after loading records:
474
+ # preload + includes, when includes doesn't turn into a JOIN.
475
+ def preload_values(relation, kind)
476
+ return nil unless relation.is_a?(ActiveRecord::Relation)
477
+
478
+ values = relation.preload_values + relation.includes_values
479
+ return nil if values.empty?
480
+ raise Skip, "includes would eager_load (JOIN) here" if relation.eager_loading?
481
+ raise Skip, "pluck with includes joins the included tables" if kind == :pluck
482
+ return nil unless %i[records one one! last].include?(kind) # count/exists? don't load records
483
+
484
+ raise Skip, "preload list is not plain symbols/hashes" unless plain_associations?(values)
485
+
486
+ values
487
+ end
488
+
489
+ def plain_associations?(value)
490
+ case value
491
+ when Symbol, String then true
492
+ when Array then value.all? { plain_associations?(it) }
493
+ when Hash then value.all? { |k, v| plain_associations?(k) && plain_associations?(v) }
494
+ else false
495
+ end
496
+ end
497
+
498
+ # Capture needs binds to tell which `?` is which argument; with prepared
499
+ # statements off (query_log_tags_enabled does that) AR inlines values
500
+ # instead. The runtime always binds its own fixed SQL, so forcing them on
501
+ # for the capture is safe. Fast-path queries carry no query-log comment.
502
+ def with_prepared_statements(model)
503
+ model.with_connection do |conn|
504
+ previous = conn.instance_variable_get(:@prepared_statements)
505
+ conn.instance_variable_set(:@prepared_statements, true)
506
+ begin
507
+ yield
508
+ ensure
509
+ conn.instance_variable_set(:@prepared_statements, previous)
510
+ end
511
+ end
512
+ end
513
+
514
+ # Overrides that call ActiveRecord's method unchanged and only touch the
515
+ # records it returns; Site does the same to fast-path records.
516
+ PASS_THROUGH = { "ArLazyPreload::Base::ClassMethods" => %i[find_by] }.freeze
517
+
518
+ # The captured SQL is only valid for every argument if the method is
519
+ # ActiveRecord's own. An app or gem override (Redmine's Project.find,
520
+ # friendly_id's finders) may pick a different query per argument.
521
+ def ensure_activerecord_method!(receiver, name)
522
+ owner = receiver.method(name).owner
523
+ return if owner.name&.start_with?("ActiveRecord::")
524
+ return if PASS_THROUGH[owner.name]&.include?(name)
525
+ return if scope_method?(receiver, name)
526
+
527
+ where = owner.singleton_class? ? owner.attached_object.name : (owner.name || "an anonymous module")
528
+ raise Skip, "#{name} overrides ActiveRecord's (defined on #{where})"
529
+ end
530
+
531
+ # A method ActiveRecord generated for a recorded scope, on the model and
532
+ # (for relations) on the relation, and not redefined by the app since.
533
+ def scope_method?(receiver, name)
534
+ model = receiver.is_a?(Class) ? receiver : receiver.model
535
+ return false unless Scopes.body_for(model, name)
536
+
537
+ generated = ->(m) { m.source_location&.first.to_s.end_with?("active_record/scoping/named.rb", "active_record/relation/delegation.rb") }
538
+ generated.(model.method(name)) && generated.(receiver.method(name))
539
+ end
540
+
541
+ def resolve_model(root)
542
+ name = root.slice
543
+ model = resolve_constant(root)
544
+ name = model.name if model.is_a?(Module) && model.name
545
+ raise Skip, "#{name} is not an ActiveRecord model" unless model.is_a?(Class) && model < ActiveRecord::Base
546
+
547
+ check_model!(model, name)
548
+ rescue NameError
549
+ raise Skip, "cannot resolve #{name}"
550
+ end
551
+
552
+ def check_model!(model, name)
553
+ raise Skip, "#{name} is abstract" if model.abstract_class?
554
+ raise Skip, "#{name} has a default_scope" if model.default_scopes.any?
555
+ model.with_connection do |conn|
556
+ raise Skip, "#{name} is not on SQLite" unless conn.is_a?(ActiveRecord::ConnectionAdapters::SQLite3Adapter)
557
+ end
558
+ raise Skip, "#{name} has no table" unless model.table_exists?
559
+
560
+ model.load_schema
561
+ model
562
+ end
563
+
564
+ # nil when the namespace can't be resolved; lookups inside it are then skipped.
565
+ def namespace_for(node)
566
+ resolve_constant(node.constant_path)
567
+ rescue NameError, Skip
568
+ nil
569
+ end
570
+
571
+ # Constant lookup as Ruby does it at this point in the file: enclosing
572
+ # lexical scopes first, then the innermost scope's ancestors, then Object.
573
+ def resolve_constant(node)
574
+ case node
575
+ when Prism::ConstantReadNode
576
+ lexical_lookup(node.name)
577
+ when Prism::ConstantPathNode
578
+ return Object.const_get(node.name, false) unless node.parent
579
+
580
+ scope = resolve_constant(node.parent)
581
+ owner = scope.ancestors.find { it.const_defined?(node.name, false) && !(it == Object && scope != Object) }
582
+ owner ? owner.const_get(node.name, false) : raise(NameError, "uninitialized constant #{node.slice}")
583
+ else
584
+ raise Skip, "unsupported constant expression #{node.slice}"
585
+ end
586
+ end
587
+
588
+ def lexical_lookup(name)
589
+ raise Skip, "cannot resolve the enclosing namespace" if @nesting.include?(nil)
590
+
591
+ @nesting.reverse_each { |scope| return scope.const_get(name, false) if scope.const_defined?(name, false) }
592
+ ((@nesting.last&.ancestors || []) + [Object]).each do |scope|
593
+ return scope.const_get(name, false) if scope.const_defined?(name, false)
594
+ end
595
+ raise NameError, "uninitialized constant #{name}"
596
+ end
597
+
598
+ def validate_terminator!(terminator)
599
+ return unless terminator
600
+
601
+ count = terminator.arguments&.arguments&.size || 0
602
+ ok = case terminator.name
603
+ when :find then count == 1
604
+ when :find_by, :find_by! then count == 1
605
+ when :exists? then count <= 1
606
+ when :pluck then count >= 1
607
+ when :sum, :maximum, :minimum, :average then count == 1
608
+ else count.zero?
609
+ end
610
+ raise Skip, "unsupported arguments to #{terminator.name}" unless ok
611
+ end
612
+
613
+ # Converts call arguments to Ruby values for the build-time ActiveRecord
614
+ # call. Literals are passed through (and end up as constants in the SQL or
615
+ # binds); anything else becomes a Sentinel and a runtime argument.
616
+ def ruby_args(call, model, args)
617
+ nodes = call.arguments&.arguments || []
618
+ raise Skip, "block argument" if call.block
619
+
620
+ case call.name
621
+ when :where, :not, :find_by, :find_by!
622
+ raise Skip, "#{call.name} needs exactly one hash" unless nodes.size == 1 && hash_node?(nodes[0])
623
+
624
+ [conditions(nodes[0], model, args)]
625
+ when :exists?
626
+ return [] if nodes.empty?
627
+ return [conditions(nodes[0], model, args)] if hash_node?(nodes[0])
628
+
629
+ # exists?(id): where(primary_key => id). false, nil, an Array or a
630
+ # record mean something else, so only an Integer or String id takes
631
+ # the fast path.
632
+ raise Skip, "exists? with a composite primary key" unless model.primary_key.is_a?(String)
633
+
634
+ id = value_or_sentinel(nodes[0], args, model.type_for_attribute(model.primary_key))
635
+ @id_args << id.index if id.is_a?(Capture::Sentinel)
636
+ [id]
637
+ when :find
638
+ [value_or_sentinel(nodes[0], args, model.type_for_attribute(model.primary_key))]
639
+ when :limit, :offset
640
+ raise Skip, "#{call.name} needs an integer literal" unless nodes.size == 1 && nodes[0].is_a?(Prism::IntegerNode)
641
+
642
+ [nodes[0].value]
643
+ when :includes, :preload
644
+ raise Skip, "#{call.name} needs arguments" if nodes.empty?
645
+
646
+ nodes.map { literal(it) }
647
+ when :sum, :maximum, :minimum, :average
648
+ column = literal(nodes[0])
649
+ unless (column.is_a?(Symbol) || column.is_a?(String)) && model.columns_hash.key?(column.to_s)
650
+ raise Skip, "#{call.name} of non-column"
651
+ end
652
+
653
+ [column]
654
+ when :group
655
+ raise Skip, "group needs arguments" if nodes.empty?
656
+
657
+ nodes.map { literal(it) }.tap do |values|
658
+ raise Skip, "group by non-column" unless values.all? { (it.is_a?(Symbol) || it.is_a?(String)) && model.columns_hash.key?(it.to_s) }
659
+ end
660
+ when :order, :pluck
661
+ raise Skip, "#{call.name} needs literal arguments" if nodes.empty?
662
+
663
+ nodes.map { call.name == :order && arel_sql(it) || literal(it) }.tap do |values|
664
+ if call.name == :pluck && !values.all? { (it.is_a?(Symbol) || it.is_a?(String)) && model.attribute_types.key?(it.to_s) }
665
+ raise Skip, "pluck of non-attribute"
666
+ end
667
+ end
668
+ else
669
+ return scope_args(call, nodes, model, args) if Scopes.body_for(model, call.name)
670
+ raise Skip, "#{call.name} is not a scope" unless nodes.empty?
671
+
672
+ [] # not a scope: ensure_activerecord_method! explains why it is skipped
673
+ end
674
+ end
675
+
676
+ # --- scopes --------------------------------------------------------------
677
+
678
+ def scope_args(call, nodes, model, args)
679
+ begin
680
+ analysis = (@scope_analyzer ||= ScopeAnalyzer.new).analyze(model, call.name)
681
+ rescue ScopeAnalyzer::NotStatic => e
682
+ raise Skip, "#{call.name} is not a static scope: #{e.message}"
683
+ end
684
+ if nodes.size != analysis.arity || nodes.any? { hash_node?(it) || it.is_a?(Prism::SplatNode) || it.is_a?(Prism::BlockArgumentNode) }
685
+ raise Skip, "unsupported arguments to scope #{call.name}"
686
+ end
687
+
688
+ nodes.each_with_index.map do |node, i|
689
+ column = analysis.param_columns[i]
690
+ next value_or_sentinel(node, args, ActiveModel::Type::Value.new) unless column
691
+
692
+ value_or_sentinel(node, args, *condition_type(model, column), list: true)
693
+ end
694
+ end
695
+
696
+ # `Arel.sql("COUNT(*) DESC")` with a plain string: fixed SQL, like a literal.
697
+ def arel_sql(node)
698
+ return nil unless node.is_a?(Prism::CallNode) && node.name == :sql && node.arguments&.arguments&.size == 1 && !node.block
699
+ return nil unless %w[Arel ::Arel].include?(node.receiver&.slice) && node.arguments.arguments[0].is_a?(Prism::StringNode)
700
+
701
+ Arel.sql(node.arguments.arguments[0].unescaped)
702
+ end
703
+
704
+ # The group columns of a grouped `count`/`sum`/..., which returns a Hash keyed by them.
705
+ def group_columns(relation, kind)
706
+ return nil unless relation.respond_to?(:group_values) && relation.group_values.any?
707
+ return nil unless %i[count calculate].include?(kind)
708
+
709
+ relation.group_values.map(&:to_s)
710
+ end
711
+
712
+ def hash_node?(node) = node.is_a?(Prism::KeywordHashNode) || node.is_a?(Prism::HashNode)
713
+
714
+ def conditions(hash, model, args)
715
+ hash.elements.to_h do |assoc|
716
+ raise Skip, "splat in conditions" unless assoc.is_a?(Prism::AssocNode)
717
+ raise Skip, "non-literal key" unless assoc.key.is_a?(Prism::SymbolNode) || assoc.key.is_a?(Prism::StringNode)
718
+
719
+ key = assoc.key.unescaped
720
+ [key.to_sym, value_or_sentinel(assoc.value, args, *condition_type(model, key), list: true)]
721
+ end
722
+ end
723
+
724
+ # The type a `where(key: v)` value is cast with and, for a belongs_to key,
725
+ # the primary key read off a record value.
726
+ def condition_type(model, key)
727
+ return [model.type_for_attribute(key), nil] if model.columns_hash.key?(key)
728
+
729
+ reflection = ScopeAnalyzer.belongs_to_key(model, key)
730
+ raise Skip, reflection.delete("`") if reflection.is_a?(String)
731
+
732
+ [model.type_for_attribute(reflection.foreign_key.to_s), Keys.join_primary_key(reflection).to_s]
733
+ end
734
+
735
+ # `list: true` for a condition value, which may also be an Array at runtime
736
+ # (see #in_lists); other collections can't be compiled.
737
+ def value_or_sentinel(node, args, type, record_key = nil, list: false)
738
+ literal(node)
739
+ rescue Skip
740
+ list &&= record_key.nil? && !Site.encrypted?(type)
741
+ if collection?(node)
742
+ raise Skip, "argument is a collection or range: #{node.slice}" unless list && !range?(node)
743
+
744
+ @collection_args[args.size] = node.slice
745
+ end
746
+ raise Skip, "heredoc argument" if heredoc?(node)
747
+
748
+ @list_args[args.size] = type if list
749
+ args << node
750
+ Capture::Sentinel.for(type, args.size - 1, record_key:)
751
+ end
752
+
753
+ def range?(node)
754
+ node = node.body.body.first while node.is_a?(Prism::ParenthesesNode) && node.body.is_a?(Prism::StatementsNode) && node.body.body.size == 1
755
+ node.is_a?(Prism::RangeNode)
756
+ end
757
+
758
+ def heredoc?(node)
759
+ return true if node.respond_to?(:opening_loc) && node.opening_loc&.slice&.start_with?("<<")
760
+
761
+ node.compact_child_nodes.any? { heredoc?(it) }
762
+ end
763
+
764
+ def collection?(node)
765
+ while node.is_a?(Prism::ParenthesesNode) && node.body.is_a?(Prism::StatementsNode) && node.body.body.size == 1
766
+ node = node.body.body.first
767
+ end
768
+ case node
769
+ when Prism::RangeNode, Prism::ArrayNode then true
770
+ when Prism::CallNode then COLLECTION_METHODS.include?(node.name)
771
+ else false
772
+ end
773
+ end
774
+
775
+ def literal(node)
776
+ case node
777
+ when Prism::SymbolNode then node.unescaped.to_sym
778
+ when Prism::StringNode then node.unescaped
779
+ when Prism::IntegerNode, Prism::FloatNode then node.value
780
+ when Prism::NilNode then nil
781
+ when Prism::TrueNode then true
782
+ when Prism::FalseNode then false
783
+ when Prism::ArrayNode then node.elements.map { literal(it) }
784
+ when Prism::HashNode, Prism::KeywordHashNode
785
+ node.elements.to_h do |assoc|
786
+ raise Skip, "splat" unless assoc.is_a?(Prism::AssocNode)
787
+
788
+ [literal(assoc.key), literal(assoc.value)]
789
+ end
790
+ else raise Skip, "not a literal: #{node&.slice}"
791
+ end
792
+ end
793
+
794
+ def bind_specs(model, query, arg_count, key: nil)
795
+ raise Skip, "argument leaked into SQL" if query.sql.include?("__ar_precompiled_arg_")
796
+
797
+ seen = []
798
+ specs = []
799
+ binds = query.binds
800
+ i = 0
801
+ while i < binds.size
802
+ bind = binds[i]
803
+ raw = bind.value_before_type_cast
804
+ if !key.nil? && !raw.is_a?(Capture::Sentinel) && raw == key
805
+ # The association owner's key, as AssociationScope binds it.
806
+ raise Skip, "owner key bound twice" if seen.include?(0)
807
+ raise Skip, "owner key is converted" unless bind.value_for_database == key
808
+
809
+ seen << 0
810
+ specs << [:key, 0, bind.name]
811
+ elsif raw.is_a?(Capture::Sentinel)
812
+ raise Skip, "argument bound twice" if seen.include?(raw.index)
813
+ raise Skip, "unexpected bind type for #{bind.name}" unless bind.type.equal?(model.type_for_attribute(bind.name))
814
+
815
+ seen << raw.index
816
+ specs << if Site.encrypted?(bind.type)
817
+ encrypted_spec(model, bind, raw.index, 1)
818
+ elsif raw.record_key
819
+ [:arg, raw.index, bind.name, raw.record_key]
820
+ else
821
+ [:arg, raw.index, bind.name]
822
+ end
823
+ elsif (expanded = extended_encrypted_bind(model, binds, i, arg_count, seen))
824
+ index, size = expanded
825
+ seen << index
826
+ specs << encrypted_spec(model, bind, index, size)
827
+ i += size
828
+ next
829
+ else
830
+ # The ciphertext would be made with the build's keys.
831
+ raise Skip, "constant compared to encrypted attribute #{bind.name}" if Site.encrypted?(model.type_for_attribute(bind.name))
832
+
833
+ value = model.with_connection { Site.db_constant(it, bind.value_for_database) }
834
+ raise Skip, "non-literal constant bind" if value.equal?(Site::FALLBACK)
835
+
836
+ specs << [:val, value, bind.name]
837
+ end
838
+ i += 1
839
+ end
840
+ raise Skip, "argument not bound" unless seen.sort == (0...arg_count).to_a
841
+
842
+ specs
843
+ end
844
+
845
+ # `[:enc, index, attribute, size, state]`: a deterministic encrypted
846
+ # attribute, bound as `size` values (the ciphertext, plus what
847
+ # extend_queries adds). `state` is the encryption setup the SQL assumes.
848
+ def encrypted_spec(model, bind, index, size)
849
+ type = model.type_for_attribute(bind.name)
850
+ raise Skip, "non-deterministic encrypted attribute #{bind.name}" unless type.deterministic?
851
+
852
+ [:enc, index, bind.name, size, Site.encryption_state(type)]
853
+ end
854
+
855
+ # With extend_queries, `where(email: x)` becomes `email IN (?, ?)`, bound
856
+ # to x already serialized by the attribute type and its previous types
857
+ # (e.g. the clear text). Which argument, and how many binds, if so.
858
+ def extended_encrypted_bind(model, binds, at, arg_count, seen)
859
+ type = model.type_for_attribute(binds[at].name)
860
+ return nil unless Site.encrypted?(type) && type.deterministic? && !type.previous_types.empty?
861
+
862
+ (0...arg_count).each do |index|
863
+ next if seen.include?(index)
864
+
865
+ sentinel = Capture::Sentinel.new(index)
866
+ forms = [type, *type.previous_types].map { it.serialize(sentinel) }
867
+ window = binds[at, forms.size]
868
+ next unless window.size == forms.size && window.all? { it.name == binds[at].name }
869
+ return [index, forms.size] if window.map(&:value_for_database) == forms
870
+ end
871
+ nil
872
+ end
873
+
874
+ # --- code generation -----------------------------------------------------
875
+
876
+ def call_site(name, range_node, args)
877
+ raise Skip, "heredoc in query chain" if heredoc?(range_node)
878
+
879
+ params = args.each_index.map { "__xs#{it}" }
880
+ fallback = +""
881
+ cursor = range_node.location.start_offset
882
+ args.each_with_index do |arg, i|
883
+ # `where(name:)` is shorthand for `where(name: name)`; spell it out in the fallback.
884
+ replacement = arg.is_a?(Prism::ImplicitNode) ? "#{arg.value.name}: #{params[i]}" : params[i]
885
+ fallback << @source.byteslice(cursor, arg.location.start_offset - cursor) << replacement
886
+ cursor = arg.location.end_offset
887
+ end
888
+ fallback << @source.byteslice(cursor, range_node.location.end_offset - cursor)
889
+
890
+ call_args = args.map do |arg|
891
+ next arg.value.name.to_s if arg.is_a?(Prism::ImplicitNode)
892
+
893
+ rewrite_range(arg, arg.location.start_offset, arg.location.end_offset)
894
+ end
895
+ call = "::ActiveRecordPrecompiled::Sites::#{name}.call"
896
+ call << "(#{call_args.join(", ")})" unless args.empty?
897
+ block_params = params.empty? ? "" : " |#{params.join(", ")}|"
898
+ # Newlines in the arguments plus newlines left in the fallback add up to
899
+ # the original count, so following lines keep their numbers.
900
+ "#{call} {#{block_params} #{fallback} }"
901
+ end
902
+
903
+ def append_definitions(body, parse)
904
+ line = "BEGIN { #{@definitions.join("; ")} } #{MARKER} v#{VERSION}\n"
905
+ if (data = parse.data_loc)
906
+ offset = data.start_offset
907
+ head = body.byteslice(0, body.bytesize - (@source.bytesize - offset))
908
+ tail = body.byteslice(head.bytesize, body.bytesize)
909
+ head << "\n" unless head.end_with?("\n")
910
+ head + line + tail
911
+ else
912
+ body << "\n" unless body.end_with?("\n")
913
+ body + line
914
+ end
915
+ end
916
+ end
917
+ end