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,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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordPrecompiled
4
+ VERSION = "0.1.0"
5
+ 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)
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler requires the gem by its name.
4
+ require "active_record_precompiled"
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: []