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,481 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "date"
|
|
6
|
+
require "active_support/time_with_zone"
|
|
7
|
+
|
|
8
|
+
module ActiveRecordPrecompiled
|
|
9
|
+
# A precompiled query. `sql` and `binds` were captured from ActiveRecord at
|
|
10
|
+
# build time; each bind is either `[:arg, index, attribute]` (a runtime
|
|
11
|
+
# argument, serialized with the attribute's type exactly like a
|
|
12
|
+
# Relation::QueryAttribute) or `[:val, value]` (a constant already in
|
|
13
|
+
# database form). `[:arg, index, attribute, key]` is a belongs_to key:
|
|
14
|
+
# a record argument binds its `key` (`where(site: site)` binds site.id).
|
|
15
|
+
# `[:enc, index, attribute, size, state]` is a deterministic encrypted
|
|
16
|
+
# attribute, encrypted at runtime (see Rewriter#encrypted_spec).
|
|
17
|
+
class Site
|
|
18
|
+
# Values ActiveRecord turns into a single `column = ?` bind. Anything else
|
|
19
|
+
# (nil, Array, Range, Relation, records, ...) changes the SQL shape.
|
|
20
|
+
SCALARS = [String, Symbol, Integer, Float, BigDecimal, true, false, Time, Date, ActiveSupport::TimeWithZone].freeze
|
|
21
|
+
FALLBACK = Object.new.freeze
|
|
22
|
+
# Arrays longer than this go to ActiveRecord (as preloads do; well under
|
|
23
|
+
# SQLite's bind limit), and at most this many IN-list shapes are cached.
|
|
24
|
+
MAX_LIST = 100
|
|
25
|
+
MAX_SHAPES = 256
|
|
26
|
+
# Published for fast-path queries only; fallbacks emit `sql.active_record` as usual.
|
|
27
|
+
EVENT = "sql.active_record_precompiled"
|
|
28
|
+
AR_EVENT = "sql.active_record"
|
|
29
|
+
# On JRuby the sqlite3 adapter is activerecord-jdbc's.
|
|
30
|
+
JDBC = RUBY_ENGINE == "jruby"
|
|
31
|
+
# ActiveRecord's query payload has `affected_rows` from 8.1 on.
|
|
32
|
+
AFFECTED_ROWS = ActiveRecord.gem_version >= Gem::Version.new("8.1")
|
|
33
|
+
QUERY_NAMES = { count: "Count", group_count: "Count", calculate: nil, group_calculate: nil, exists: "Exists?", pluck: "Pluck" }.freeze
|
|
34
|
+
|
|
35
|
+
def self.define(name, **options)
|
|
36
|
+
Sites.send(:remove_const, name) if Sites.const_defined?(name, false)
|
|
37
|
+
Sites.const_set(name, new(**options))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Identifies the table layout the SQL was built against. Column order
|
|
41
|
+
# matters: it decides attribute order whenever the SQL lists columns.
|
|
42
|
+
def self.fingerprint(columns) = Digest::SHA256.hexdigest(columns.map { it.join(":") }.join(","))[0, 16]
|
|
43
|
+
def self.schema_of(model) = fingerprint(model.columns.map { [it.name, it.sql_type] })
|
|
44
|
+
|
|
45
|
+
# What a Site needs besides its SQL and binds, one object for all Sites
|
|
46
|
+
# with the same: every Site is allocated as large as the Site with the
|
|
47
|
+
# most instance variables, so they are kept here. `lists` are the bind
|
|
48
|
+
# positions an Array may take (see Rewriter#in_lists), `ids` the argument
|
|
49
|
+
# indices that must be an Integer or String id, `calc` [operation, column]
|
|
50
|
+
# for sum/maximum/minimum/average. `lazy: false` for a bare
|
|
51
|
+
# `Model.find(id)`: ActiveRecord answers it from a cached statement, not
|
|
52
|
+
# Relation#load, so ar_lazy_preload doesn't see it. The preload
|
|
53
|
+
# `registry` and `trees` are built on first use from the other members.
|
|
54
|
+
STATIC_OPTIONS = %i[model_name schema lists ids group calc lazy preload plans].freeze
|
|
55
|
+
Options = Struct.new(*STATIC_OPTIONS, :registry, :trees) do
|
|
56
|
+
def self.for(**options)
|
|
57
|
+
values = ActiveRecordPrecompiled.intern(options.values_at(*STATIC_OPTIONS))
|
|
58
|
+
OPTIONS[values] ||= new(*values)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
OPTIONS = {}
|
|
62
|
+
|
|
63
|
+
attr_reader :kind, :sql, :binds
|
|
64
|
+
|
|
65
|
+
def initialize(model:, kind:, sql:, binds:, schema: nil, lists: nil, ids: nil, group: nil, calc: nil, preload: nil, preload_plans: nil, lazy: true)
|
|
66
|
+
@options = Options.for(model_name: model, schema:, lists:, ids:, group:, calc:, lazy:, preload:,
|
|
67
|
+
plans: preload_plans&.map { Preload::Plan.for(it) })
|
|
68
|
+
@kind = kind
|
|
69
|
+
@sql = -sql
|
|
70
|
+
@binds = ActiveRecordPrecompiled.intern(binds)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The model, or false if its schema differs from the build's.
|
|
74
|
+
def model
|
|
75
|
+
@model = verified_model if @model.nil?
|
|
76
|
+
@model
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def call(*args, &fallback)
|
|
80
|
+
options = @options
|
|
81
|
+
return fallback.call(*args) unless ActiveRecordPrecompiled.enabled? && (model = self.model) && model.current_scope.nil?
|
|
82
|
+
return fallback.call(*args) if options.preload && temporary_lazy_preload?
|
|
83
|
+
return fallback.call(*args) if options.ids&.any? { !(args[it].is_a?(Integer) || args[it].is_a?(String)) }
|
|
84
|
+
|
|
85
|
+
result = model.with_connection do |conn|
|
|
86
|
+
next FALLBACK unless conn.is_a?(ActiveRecord::ConnectionAdapters::SQLite3Adapter)
|
|
87
|
+
|
|
88
|
+
values, lists = type_cast_binds(conn, model, args)
|
|
89
|
+
next FALLBACK if values.equal?(FALLBACK)
|
|
90
|
+
|
|
91
|
+
execute(conn, lists ? shape(lists)[0] : @sql, values, lists)
|
|
92
|
+
end
|
|
93
|
+
return fallback.call(*args) if result.equal?(FALLBACK)
|
|
94
|
+
|
|
95
|
+
materialize(model, *result) { fallback.call(*args) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# Checked once per site: if the runtime schema is not the one the build
|
|
101
|
+
# saw, the frozen SQL may select the wrong columns, so defer to AR for good.
|
|
102
|
+
def verified_model
|
|
103
|
+
model = Object.const_get(@options.model_name)
|
|
104
|
+
return model if @options.schema.nil? || Site.schema_of(model) == @options.schema
|
|
105
|
+
|
|
106
|
+
ActiveRecordPrecompiled.warn("activerecord-precompiled: #{@options.model_name} schema differs from build time; " \
|
|
107
|
+
"using ActiveRecord for this query. Build from a fresh checkout after db:migrate.")
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# [values, lists]: the bind values, and for each bind given an Array the
|
|
112
|
+
# number of values in its IN list (nil if there are none).
|
|
113
|
+
def type_cast_binds(conn, model, args)
|
|
114
|
+
values = []
|
|
115
|
+
lists = nil
|
|
116
|
+
@binds.each_with_index do |bind, at|
|
|
117
|
+
case bind[0]
|
|
118
|
+
when :val then values << bind[1]
|
|
119
|
+
when :key then values << Site.db_value(conn, args[bind[1]]) # checked by AssocSite
|
|
120
|
+
when :enc then values.concat(encrypted_binds(conn, model, args, bind) || (return FALLBACK))
|
|
121
|
+
else
|
|
122
|
+
value = args[bind[1]]
|
|
123
|
+
if value.is_a?(Array) && @options.lists&.include?(at)
|
|
124
|
+
return FALLBACK if value.empty? || value.size > MAX_LIST
|
|
125
|
+
|
|
126
|
+
# One value is the scalar SQL, as in PredicateBuilder::ArrayHandler.
|
|
127
|
+
if value.size > 1
|
|
128
|
+
value.each do |element|
|
|
129
|
+
cast = cast_arg(conn, model, bind, element, list: true)
|
|
130
|
+
return FALLBACK if cast.equal?(FALLBACK)
|
|
131
|
+
|
|
132
|
+
values << cast
|
|
133
|
+
end
|
|
134
|
+
(lists ||= {})[at] = value.size
|
|
135
|
+
next
|
|
136
|
+
end
|
|
137
|
+
value = value[0]
|
|
138
|
+
end
|
|
139
|
+
cast = cast_arg(conn, model, bind, value)
|
|
140
|
+
return FALLBACK if cast.equal?(FALLBACK)
|
|
141
|
+
|
|
142
|
+
values << cast
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
[values, lists]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# An argument in database form, or FALLBACK if ActiveRecord would build
|
|
149
|
+
# different SQL for it. In an IN list (`list`), HomogeneousIn drops
|
|
150
|
+
# values that don't serialize, so those fall back too.
|
|
151
|
+
def cast_arg(conn, model, bind, value, list: false)
|
|
152
|
+
value = value.public_send(bind[3]) if bind[3] && value.is_a?(ActiveRecord::Base)
|
|
153
|
+
return FALLBACK unless SCALARS.any? { it === value }
|
|
154
|
+
return FALLBACK if value.respond_to?(:infinite?) && value.infinite?
|
|
155
|
+
|
|
156
|
+
type = model.type_for_attribute(bind[2])
|
|
157
|
+
return FALLBACK unless type.serializable?(value)
|
|
158
|
+
|
|
159
|
+
serialized = type.serialize(value)
|
|
160
|
+
# Mirrors QueryAttribute#nil?: AR would emit `IS NULL` here.
|
|
161
|
+
return FALLBACK if serialized.nil? && (list || type.respond_to?(:subtype) || type.respond_to?(:normalizer))
|
|
162
|
+
|
|
163
|
+
Site.db_value(conn, serialized)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# [SQL, bind names] for the IN lists of these sizes (bind position =>
|
|
167
|
+
# size; nil for none), cached per shape; the names are filled in when
|
|
168
|
+
# first needed.
|
|
169
|
+
def shape(lists)
|
|
170
|
+
(@shapes ||= {})[lists] || begin
|
|
171
|
+
shape = [lists ? list_sql(lists) : @sql, nil]
|
|
172
|
+
@shapes.size < MAX_SHAPES ? (@shapes[lists] = shape) : shape
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The SQL with IN lists of these sizes.
|
|
177
|
+
def list_sql(lists)
|
|
178
|
+
sql = +@sql
|
|
179
|
+
lists.map { |at, size| [*Site.list_span(@sql, at), size] }.sort_by { -it[0] }.each do |start, stop, negated, size|
|
|
180
|
+
sql[start...stop] = "#{negated ? " NOT IN" : " IN"} (#{Array.new(size, "?").join(", ")})"
|
|
181
|
+
end
|
|
182
|
+
sql.freeze
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# [start, stop, negated]: where the `= ?` (`!= ?`) of bind `at` is, the
|
|
186
|
+
# part an IN list replaces.
|
|
187
|
+
def self.list_span(sql, at)
|
|
188
|
+
question = placeholder_offset(sql, at)
|
|
189
|
+
negated = sql[question - 4, 5] == " != ?"
|
|
190
|
+
[negated ? question - 4 : question - 3, question + 1, negated]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Offset of the n-th `?` placeholder, skipping quoted strings and names.
|
|
194
|
+
def self.placeholder_offset(sql, n)
|
|
195
|
+
seen = -1
|
|
196
|
+
sql.scan(/'(?:[^']|'')*'|"(?:[^"]|"")*"|\?/) do
|
|
197
|
+
next unless $~[0] == "?"
|
|
198
|
+
|
|
199
|
+
seen += 1
|
|
200
|
+
return $~.begin(0) if seen == n
|
|
201
|
+
end
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A deterministic encrypted attribute: the ciphertext, then (with
|
|
206
|
+
# extend_queries) the value as each previous type stores it. Only Strings
|
|
207
|
+
# get the extra values, so anything else, or a different encryption setup
|
|
208
|
+
# than the build saw, would change the SQL.
|
|
209
|
+
def encrypted_binds(conn, model, args, (_, index, attribute, size, state))
|
|
210
|
+
value = args[index]
|
|
211
|
+
type = model.type_for_attribute(attribute)
|
|
212
|
+
return nil unless value.is_a?(String) && Site.encrypted?(type) && Site.encryption_state(type) == state
|
|
213
|
+
|
|
214
|
+
types = size == 1 ? [type] : [type, *type.previous_types]
|
|
215
|
+
return nil unless types.size == size
|
|
216
|
+
|
|
217
|
+
types.map { Site.db_value(conn, it.serialize(value)) }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def self.encrypted?(type)
|
|
221
|
+
defined?(ActiveRecord::Encryption::EncryptedAttributeType) && type.is_a?(ActiveRecord::Encryption::EncryptedAttributeType)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# What decides how ActiveRecord binds a deterministic encrypted value.
|
|
225
|
+
def self.encryption_state(type)
|
|
226
|
+
extended = defined?(ActiveRecord::Encryption::ExtendedDeterministicQueries::RelationQueries) &&
|
|
227
|
+
ActiveRecord::Relation <= ActiveRecord::Encryption::ExtendedDeterministicQueries::RelationQueries
|
|
228
|
+
[type.previous_types.size, extended ? 1 : 0]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def execute(conn, sql, values, lists)
|
|
232
|
+
Site.execute(conn, sql, values, "#{@options.model_name} #{QUERY_NAMES.fetch(@kind, "Load") || @options.calc[0].capitalize}", self, lists)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
public
|
|
236
|
+
|
|
237
|
+
# [name, type] per bind, for the `sql.active_record` payload: the
|
|
238
|
+
# attribute names let log filtering (filter_attributes) apply.
|
|
239
|
+
def bind_names(lists)
|
|
240
|
+
shape(lists)[1] ||= begin
|
|
241
|
+
model = self.model
|
|
242
|
+
@binds.each_with_index.flat_map do |bind, at|
|
|
243
|
+
case bind[0]
|
|
244
|
+
when :val, :key then [[bind[2], ActiveModel::Type.default_value]]
|
|
245
|
+
when :enc then Array.new(bind[3], [bind[2], model.type_for_attribute(bind[2])])
|
|
246
|
+
else Array.new(lists&.[](at) || 1, [bind[2], model.type_for_attribute(bind[2])])
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
private
|
|
253
|
+
|
|
254
|
+
CACHE_KEY = :active_record_precompiled
|
|
255
|
+
|
|
256
|
+
# Runs precompiled SQL; [columns, rows]. Shared with preload plans.
|
|
257
|
+
#
|
|
258
|
+
# With ActiveRecord's query cache on (QueryCache#select_all), the result
|
|
259
|
+
# is kept in the connection's own cache store: the same store, so the
|
|
260
|
+
# same lifetime (cleared by any write, by `uncached`, LRU-evicted), under
|
|
261
|
+
# a key of our own. A hit is published like ActiveRecord's ("CACHE").
|
|
262
|
+
def self.execute(conn, sql, values, name, names = nil, shape = nil)
|
|
263
|
+
return query(conn, sql, values, name, names, shape) unless conn.query_cache_enabled
|
|
264
|
+
|
|
265
|
+
key = [CACHE_KEY, sql, values]
|
|
266
|
+
hit = true
|
|
267
|
+
result = conn.lock.synchronize do
|
|
268
|
+
conn.query_cache.compute_if_absent(key) do
|
|
269
|
+
hit = false
|
|
270
|
+
query(conn, sql, values, name, names, shape)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
cache_hit(conn, sql, values, name, names, shape, result[1].size) if hit
|
|
274
|
+
result
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# QueryCache#cache_notification_info, for a hit.
|
|
278
|
+
def self.cache_hit(conn, sql, values, name, names, shape, row_count)
|
|
279
|
+
notifier = ActiveSupport::Notifications.notifier
|
|
280
|
+
if ActiveRecordPrecompiled.active_record_events? && notifier.listening?(AR_EVENT)
|
|
281
|
+
ActiveSupport::Notifications.instrumenter.instrument(
|
|
282
|
+
AR_EVENT,
|
|
283
|
+
sql: sql, binds: attributes(names&.bind_names(shape), values), type_casted_binds: values, name: name,
|
|
284
|
+
connection: conn, transaction: conn.current_transaction.user_transaction.presence, cached: true,
|
|
285
|
+
row_count: row_count, precompiled: true,
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
if notifier.listening?(EVENT)
|
|
289
|
+
ActiveSupport::Notifications.instrumenter.instrument(EVENT, sql: sql, name: name, binds: values, cached: true, row_count: row_count)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Instrumented as ActiveRecord instruments a query (AbstractAdapter#log),
|
|
294
|
+
# so logs, APM and query counts see it, and as `sql.active_record_precompiled`.
|
|
295
|
+
# `names.bind_names(shape)` gives the binds' names, only when needed.
|
|
296
|
+
def self.query(conn, sql, values, name, names = nil, shape = nil)
|
|
297
|
+
notifier = ActiveSupport::Notifications.notifier
|
|
298
|
+
own = notifier.listening?(EVENT)
|
|
299
|
+
ar = ActiveRecordPrecompiled.active_record_events? && notifier.listening?(AR_EVENT)
|
|
300
|
+
return run(conn, sql, values) unless own || ar
|
|
301
|
+
|
|
302
|
+
return instrument_own(conn, sql, values, name) unless ar
|
|
303
|
+
|
|
304
|
+
binds = attributes(names&.bind_names(shape), values)
|
|
305
|
+
payload = {
|
|
306
|
+
sql: sql, name: name, binds: binds, type_casted_binds: values, async: false, allow_retry: false,
|
|
307
|
+
connection: conn, transaction: conn.current_transaction.user_transaction.presence,
|
|
308
|
+
row_count: 0, precompiled: true,
|
|
309
|
+
}
|
|
310
|
+
payload[:affected_rows] = 0 if AFFECTED_ROWS
|
|
311
|
+
ActiveSupport::Notifications.instrumenter.instrument(AR_EVENT, payload) do
|
|
312
|
+
(own ? instrument_own(conn, sql, values, name) : run(conn, sql, values)).tap { payload[:row_count] = it[1].size }
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def self.instrument_own(conn, sql, values, name)
|
|
317
|
+
ActiveSupport::Notifications.instrumenter.instrument(EVENT, sql: sql, name: name, binds: values) do |payload|
|
|
318
|
+
run(conn, sql, values).tap { payload[:row_count] = it[1].size }
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def self.attributes(names, values)
|
|
323
|
+
values.each_with_index.map do |value, i|
|
|
324
|
+
name, type = names&.[](i)
|
|
325
|
+
ActiveModel::Attribute.with_cast_value(name, value, type || ActiveModel::Type.default_value)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# The same steps as SQLite3::DatabaseStatements#perform_query, minus
|
|
330
|
+
# logging, query cache and Arel compilation.
|
|
331
|
+
def self.run(conn, sql, values)
|
|
332
|
+
return run_jdbc(conn, sql, values) if JDBC
|
|
333
|
+
conn.__send__(:with_raw_connection) do |db|
|
|
334
|
+
stmt = statement(conn, db, sql)
|
|
335
|
+
stmt.reset!
|
|
336
|
+
stmt.bind_params(values) unless values.empty?
|
|
337
|
+
rows = stmt.to_a
|
|
338
|
+
conn.__send__(:verified!)
|
|
339
|
+
[stmt.columns, rows]
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# A serialized value as the driver binds it. activerecord-jdbc binds
|
|
344
|
+
# serialized values itself (a Time becomes "... 03:04:05.000000"), so on
|
|
345
|
+
# JRuby it is left as is, as ActiveRecord leaves it.
|
|
346
|
+
def self.db_value(conn, value) = JDBC ? value : conn.type_cast(value)
|
|
347
|
+
|
|
348
|
+
# Constants baked into generated code: plain values only.
|
|
349
|
+
def self.db_constant(conn, value)
|
|
350
|
+
value = db_value(conn, value)
|
|
351
|
+
[String, Integer, Float, NilClass, *([TrueClass, FalseClass] if JDBC)].any? { it === value } ? value : FALLBACK
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# JRuby: activerecord-jdbc's raw connection runs the prepared statement,
|
|
355
|
+
# as its internal_exec_query does inside `log` (instrumented above instead).
|
|
356
|
+
def self.run_jdbc(conn, sql, values)
|
|
357
|
+
result = conn.__send__(:with_raw_connection) do |raw|
|
|
358
|
+
if values.empty?
|
|
359
|
+
raw.execute_query(sql)
|
|
360
|
+
else
|
|
361
|
+
binds = values.map { ActiveModel::Attribute.with_cast_value(nil, it, ActiveModel::Type.default_value) }
|
|
362
|
+
cached = conn.__send__(:fetch_cached_statement, sql) if conn.instance_variable_get(:@jdbc_statement_cache_enabled)
|
|
363
|
+
raw.execute_prepared_query(sql, binds, cached)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
[result.columns, result.rows]
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def self.statement(conn, db, sql)
|
|
370
|
+
pool = conn.instance_variable_get(:@statements)
|
|
371
|
+
if pool
|
|
372
|
+
pool[sql] ||= db.prepare(sql)
|
|
373
|
+
else
|
|
374
|
+
cache = (db.instance_variable_get(:@__ar_precompiled) || db.instance_variable_set(:@__ar_precompiled, {}))
|
|
375
|
+
stmt = cache[sql]
|
|
376
|
+
stmt = cache[sql] = db.prepare(sql) if stmt.nil? || stmt.closed?
|
|
377
|
+
stmt
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# ar_lazy_preload's auto_preload: Relation#load registers what it loaded,
|
|
382
|
+
# so the records' associations load in batches later.
|
|
383
|
+
def self.lazy_preload(records)
|
|
384
|
+
return unless defined?(ArLazyPreload) && ArLazyPreload.config.auto_preload?
|
|
385
|
+
|
|
386
|
+
ArLazyPreload::Context.register(records: records)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def materialize(model, columns, rows)
|
|
390
|
+
case @kind
|
|
391
|
+
when :records then load_records(model, columns, rows)
|
|
392
|
+
when :one then rows.empty? ? nil : load_records(model, columns, rows[0, 1]).first
|
|
393
|
+
when :one! then rows.empty? ? yield : load_records(model, columns, rows[0, 1]).first
|
|
394
|
+
when :last then rows.empty? ? nil : load_records(model, columns, [rows.last]).first
|
|
395
|
+
when :count then rows[0][0].to_i
|
|
396
|
+
when :group_count then group_count(model, rows)
|
|
397
|
+
when :calculate then calculated(model, rows.dig(0, 0))
|
|
398
|
+
when :group_calculate then grouped(model, rows) { calculated(model, it) }
|
|
399
|
+
when :exists then !rows.empty?
|
|
400
|
+
when :pluck then pluck(model, columns, rows)
|
|
401
|
+
else raise ArgumentError, "unknown site kind #{@kind.inspect}"
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# As Relation#load: instantiate, then hand includes/preload to ActiveRecord.
|
|
406
|
+
def load_records(model, columns, rows)
|
|
407
|
+
records = model._load_from_sql(ActiveRecord::Result.new(columns, rows))
|
|
408
|
+
preload = @options.preload
|
|
409
|
+
if preload && !records.empty?
|
|
410
|
+
registry = preload_registry
|
|
411
|
+
preload.each_with_index do |associations, i|
|
|
412
|
+
if (tree = preload_tree(model, i, associations, registry))
|
|
413
|
+
tree.call(records, registry)
|
|
414
|
+
else
|
|
415
|
+
Preload.with(registry) { ActiveRecord::Associations::Preloader.new(records: records, associations: associations).call }
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
Site.lazy_preload(records) if @options.lazy
|
|
420
|
+
records
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Inside ar_lazy_preload's TemporaryPreloadConfig.within_context, records
|
|
424
|
+
# preloaded by includes/preload get a context too; ActiveRecord does that.
|
|
425
|
+
def temporary_lazy_preload?
|
|
426
|
+
defined?(ArLazyPreload::Contexts::TemporaryPreloadConfig) && ArLazyPreload::Contexts::TemporaryPreloadConfig.enabled?
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def preload_registry
|
|
430
|
+
return nil unless @options.plans
|
|
431
|
+
|
|
432
|
+
@options.registry ||= Preload.registry(@options.plans)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# A Preload::Tree for the i-th includes/preload value, if it qualifies;
|
|
436
|
+
# built once, from the plans that passed verification.
|
|
437
|
+
def preload_tree(model, i, associations, registry)
|
|
438
|
+
return nil unless registry
|
|
439
|
+
|
|
440
|
+
trees = (@options.trees ||= {})
|
|
441
|
+
return trees[i] if trees.key?(i)
|
|
442
|
+
|
|
443
|
+
plans, inner = @options.plans.select(&:loader_query).partition { it.via.nil? }
|
|
444
|
+
trees[i] = Preload::Tree.build(model, associations, plans.to_h { [[it.owner_name, it.name], it] }, inner.to_h { [it.via, it] })
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# As a grouped Calculations#count: the count comes first, then the group
|
|
448
|
+
# columns, cast with their attribute types; one column keys by its value.
|
|
449
|
+
def group_count(model, rows) = grouped(model, rows, &:to_i)
|
|
450
|
+
|
|
451
|
+
def grouped(model, rows)
|
|
452
|
+
types = @options.group.map { model.type_for_attribute(it) }
|
|
453
|
+
rows.each_with_object({}) do |row, result|
|
|
454
|
+
key = types.size == 1 ? types[0].deserialize(row[1]) : types.each_with_index.map { |type, i| type.deserialize(row[i + 1]) }
|
|
455
|
+
result[key] = yield row[0]
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Calculations#type_cast_calculated_value, with the column's type.
|
|
460
|
+
def calculated(model, value)
|
|
461
|
+
operation, column = @options.calc
|
|
462
|
+
type = model.type_for_attribute(column)
|
|
463
|
+
type = type.subtype if type.is_a?(ActiveRecord::Enum::EnumType)
|
|
464
|
+
case operation
|
|
465
|
+
when "sum" then type.deserialize(value || 0)
|
|
466
|
+
when "average" then %i[integer decimal].include?(type.type) ? value&.to_d : type.deserialize(value)
|
|
467
|
+
else type.deserialize(value)
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def pluck(model, columns, rows)
|
|
472
|
+
types = columns.map { model.type_for_attribute(it) }
|
|
473
|
+
if types.one?
|
|
474
|
+
type = types[0]
|
|
475
|
+
rows.map { |(value)| type.deserialize(value) }
|
|
476
|
+
else
|
|
477
|
+
rows.map { |row| Array.new(row.size) { types[it].deserialize(row[it]) } }
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "active_record_precompiled/version"
|
|
5
|
+
require "active_record_precompiled/keys"
|
|
6
|
+
require "active_record_precompiled/site"
|
|
7
|
+
require "active_record_precompiled/friendly_site"
|
|
8
|
+
require "active_record_precompiled/assoc_site"
|
|
9
|
+
require "active_record_precompiled/preload"
|
|
10
|
+
require "active_record_precompiled/scopes"
|
|
11
|
+
|
|
12
|
+
# Ahead-of-time compiler for ActiveRecord queries on SQLite.
|
|
13
|
+
#
|
|
14
|
+
# At deploy time ActiveRecordPrecompiled::Build rewrites query chains such as
|
|
15
|
+
# `User.where(name: n).first` into calls on a precompiled ActiveRecordPrecompiled::Site,
|
|
16
|
+
# which runs ActiveRecord's own SQL directly on the SQLite3 handle. The
|
|
17
|
+
# original chain is kept as a block and used whenever the fast path does not
|
|
18
|
+
# apply (nil/array arguments, an active `scoping` block, other adapters, ...).
|
|
19
|
+
module ActiveRecordPrecompiled
|
|
20
|
+
# Holds one constant per rewritten call site, defined by the BEGIN block
|
|
21
|
+
# appended to each rewritten file.
|
|
22
|
+
module Sites; end
|
|
23
|
+
|
|
24
|
+
INTERNED = {}
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
attr_writer :enabled, :active_record_events
|
|
28
|
+
|
|
29
|
+
def enabled? = @enabled != false
|
|
30
|
+
|
|
31
|
+
# Fast-path queries are instrumented as `sql.active_record` (logs, APM,
|
|
32
|
+
# query counts, the request's "ActiveRecord: ...ms"); false skips that.
|
|
33
|
+
def active_record_events? = @active_record_events != false
|
|
34
|
+
|
|
35
|
+
# One frozen copy of each string and (nested) Array the BEGIN blocks
|
|
36
|
+
# define: sites repeat model names, fingerprints, binds and bind tuples.
|
|
37
|
+
def intern(value)
|
|
38
|
+
case value
|
|
39
|
+
when String then -value
|
|
40
|
+
when Array
|
|
41
|
+
value = value.map { intern(it) }.freeze
|
|
42
|
+
INTERNED[value] ||= value
|
|
43
|
+
else value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def warn(message)
|
|
48
|
+
logger = defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
49
|
+
logger ? logger.warn(message) : Kernel.warn(message)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
autoload :Rewriter, "active_record_precompiled/rewriter"
|
|
54
|
+
autoload :Capture, "active_record_precompiled/capture"
|
|
55
|
+
autoload :Build, "active_record_precompiled/build"
|
|
56
|
+
autoload :ScopeAnalyzer, "active_record_precompiled/scope_analyzer"
|
|
57
|
+
autoload :ScopeCheck, "active_record_precompiled/scope_check"
|
|
58
|
+
autoload :SchemaDump, "active_record_precompiled/schema_dump"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
ActiveRecordPrecompiled::Scopes.install!
|
|
62
|
+
ActiveRecordPrecompiled::Preload.install!
|
|
63
|
+
|
|
64
|
+
require "active_record_precompiled/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activerecord-precompiled
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Sampo Kuokkanen
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '8.3'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '8.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '8.3'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: prism
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '1.2'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '1.2'
|
|
46
|
+
description: |
|
|
47
|
+
At deploy time, activerecord-precompiled rewrites simple ActiveRecord query chains in your
|
|
48
|
+
application source (e.g. `User.where(name: n).first`) into direct calls on the
|
|
49
|
+
SQLite3 raw connection with SQL generated by ActiveRecord itself. The original
|
|
50
|
+
expression is kept as a fallback for any case the fast path does not cover.
|
|
51
|
+
executables:
|
|
52
|
+
- ar-precompile
|
|
53
|
+
extensions: []
|
|
54
|
+
extra_rdoc_files: []
|
|
55
|
+
files:
|
|
56
|
+
- CHANGELOG.md
|
|
57
|
+
- LICENSE.txt
|
|
58
|
+
- README.md
|
|
59
|
+
- exe/ar-precompile
|
|
60
|
+
- lib/active_record_precompiled.rb
|
|
61
|
+
- lib/active_record_precompiled/assoc_site.rb
|
|
62
|
+
- lib/active_record_precompiled/build.rb
|
|
63
|
+
- lib/active_record_precompiled/capture.rb
|
|
64
|
+
- lib/active_record_precompiled/friendly_site.rb
|
|
65
|
+
- lib/active_record_precompiled/keys.rb
|
|
66
|
+
- lib/active_record_precompiled/preload.rb
|
|
67
|
+
- lib/active_record_precompiled/railtie.rb
|
|
68
|
+
- lib/active_record_precompiled/rewriter.rb
|
|
69
|
+
- lib/active_record_precompiled/schema_dump.rb
|
|
70
|
+
- lib/active_record_precompiled/scope_analyzer.rb
|
|
71
|
+
- lib/active_record_precompiled/scope_check.rb
|
|
72
|
+
- lib/active_record_precompiled/scopes.rb
|
|
73
|
+
- lib/active_record_precompiled/site.rb
|
|
74
|
+
- lib/active_record_precompiled/version.rb
|
|
75
|
+
- lib/activerecord-precompiled.rb
|
|
76
|
+
homepage: https://github.com/sampokuokkanen/activerecord-precompiled
|
|
77
|
+
licenses:
|
|
78
|
+
- MIT
|
|
79
|
+
metadata:
|
|
80
|
+
source_code_uri: https://github.com/sampokuokkanen/activerecord-precompiled
|
|
81
|
+
changelog_uri: https://github.com/sampokuokkanen/activerecord-precompiled/blob/main/CHANGELOG.md
|
|
82
|
+
bug_tracker_uri: https://github.com/sampokuokkanen/activerecord-precompiled/issues
|
|
83
|
+
rubygems_mfa_required: 'true'
|
|
84
|
+
rdoc_options: []
|
|
85
|
+
require_paths:
|
|
86
|
+
- lib
|
|
87
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
88
|
+
requirements:
|
|
89
|
+
- - ">="
|
|
90
|
+
- !ruby/object:Gem::Version
|
|
91
|
+
version: '3.4'
|
|
92
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - ">="
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0'
|
|
97
|
+
requirements: []
|
|
98
|
+
rubygems_version: 4.0.20
|
|
99
|
+
specification_version: 4
|
|
100
|
+
summary: 'assets:precompile for ActiveRecord queries: chains rewritten at deploy time
|
|
101
|
+
into prepared SQLite statements'
|
|
102
|
+
test_files: []
|