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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +284 -0
- data/exe/ar-precompile +47 -0
- data/lib/active_record_precompiled/assoc_site.rb +65 -0
- data/lib/active_record_precompiled/build.rb +52 -0
- data/lib/active_record_precompiled/capture.rb +94 -0
- data/lib/active_record_precompiled/friendly_site.rb +49 -0
- data/lib/active_record_precompiled/keys.rb +41 -0
- data/lib/active_record_precompiled/preload.rb +619 -0
- data/lib/active_record_precompiled/railtie.rb +46 -0
- data/lib/active_record_precompiled/rewriter.rb +917 -0
- data/lib/active_record_precompiled/schema_dump.rb +181 -0
- data/lib/active_record_precompiled/scope_analyzer.rb +246 -0
- data/lib/active_record_precompiled/scope_check.rb +59 -0
- data/lib/active_record_precompiled/scopes.rb +32 -0
- data/lib/active_record_precompiled/site.rb +481 -0
- data/lib/active_record_precompiled/version.rb +5 -0
- data/lib/active_record_precompiled.rb +64 -0
- data/lib/activerecord-precompiled.rb +4 -0
- metadata +102 -0
|
@@ -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
|