forest_liana 9.22.1 → 9.22.2
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 +4 -4
- data/app/serializers/forest_liana/serializer_factory.rb +14 -1
- data/app/services/forest_liana/base_getter.rb +164 -1
- data/app/services/forest_liana/has_many_getter.rb +9 -4
- data/app/services/forest_liana/resources_getter.rb +12 -2
- data/app/services/forest_liana/schema_utils.rb +1 -1
- data/lib/forest_liana/version.rb +1 -1
- data/spec/dummy/app/models/car.rb +10 -1
- data/spec/dummy/app/models/driver.rb +4 -1
- data/spec/dummy/app/models/manufacturer.rb +8 -0
- data/spec/dummy/app/models/product.rb +11 -0
- data/spec/dummy/db/migrate/20260924120000_add_maker_name_to_products.rb +5 -0
- data/spec/dummy/db/schema.rb +2 -1
- data/spec/requests/query_footprint_spec.rb +350 -5
- data/spec/services/forest_liana/base_getter_spec.rb +281 -0
- data/spec/services/forest_liana/schema_utils_spec.rb +57 -0
- data/spec/services/forest_liana/serializer_factory_spec.rb +167 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2519878c68b98965bc3b8e31cf9ae9e8253f9d215cf724d99233e55cb37ab8c5
|
|
4
|
+
data.tar.gz: ae92f459f29ab022290ad09f33e9d1cec654d86fce2784cb425e8c8924b87e59
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6aaa74f4333c30a763077f5ef1e03247e9c42fd190608ef453263406485ecee48aae37fe8b8b927be1e5fadcf5eb12105c9ea9f5e1b43d7e6e66f78d6a1799af
|
|
7
|
+
data.tar.gz: 18e7851795ff42410beb2a87db9fb0cec346e4734cd04401461974529bf3d8e4a4ee61fca0e1d143428447f199b6c42cafe48f7c40a4e2d0c2841773e4bf254c
|
|
@@ -222,7 +222,20 @@ module ForestLiana
|
|
|
222
222
|
if reflection_primary_key != klass_primary_key
|
|
223
223
|
data[attribute_name] = attr_data.merge({
|
|
224
224
|
attr_or_block: proc {
|
|
225
|
-
|
|
225
|
+
# The find_by below reads once per row, undoing any preload, and
|
|
226
|
+
# resolves less than ActiveRecord does: it drops the association's
|
|
227
|
+
# own scope and matches a nil key against a target row whose key is
|
|
228
|
+
# NULL too. Both branches answer the same record only once it is
|
|
229
|
+
# aligned on the preloader, which is what scope_for does — and what
|
|
230
|
+
# raises on a relation declaring no scope at all, the common case.
|
|
231
|
+
association = object.association(attribute_name)
|
|
232
|
+
next association.target if association.loaded? && !association.stale_target?
|
|
233
|
+
|
|
234
|
+
key = object.send(relation.foreign_key)
|
|
235
|
+
next nil if key.nil?
|
|
236
|
+
|
|
237
|
+
scoped = relation.scope ? relation.scope_for(relation.klass.all, object) : relation.klass
|
|
238
|
+
scoped.find_by(reflection_primary_key => key)
|
|
226
239
|
}
|
|
227
240
|
})
|
|
228
241
|
next
|
|
@@ -38,7 +38,9 @@ module ForestLiana
|
|
|
38
38
|
polymorphic, preload_loads = analyze_associations(resource)
|
|
39
39
|
result = records.eager_load(@includes.uniq - preload_loads - polymorphic - @optional_includes)
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
if Rails::VERSION::MAJOR >= 7 && force_preload
|
|
42
|
+
result = result.preload(selectable_preloads(result, preload_loads))
|
|
43
|
+
end
|
|
42
44
|
|
|
43
45
|
result
|
|
44
46
|
end
|
|
@@ -48,6 +50,7 @@ module ForestLiana
|
|
|
48
50
|
# instance_eval, once per record — loaded here in one query for the whole page instead.
|
|
49
51
|
def apply_smart_field_preloads(records)
|
|
50
52
|
preloads = smart_field_preloads
|
|
53
|
+
preloads = preloads.slice(*selectable_preloads(records, preloads.keys))
|
|
51
54
|
|
|
52
55
|
preloads.empty? ? records : records.preload(preloads)
|
|
53
56
|
end
|
|
@@ -192,6 +195,153 @@ module ForestLiana
|
|
|
192
195
|
end
|
|
193
196
|
end
|
|
194
197
|
|
|
198
|
+
# To-one only: a filter puts a to-many in @includes too, and preloading that would read every
|
|
199
|
+
# child row of a page that never displays them.
|
|
200
|
+
PRELOADABLE_CROSS_DATABASE_MACROS = [:belongs_to, :has_one].freeze
|
|
201
|
+
|
|
202
|
+
def cross_database_associations(resource)
|
|
203
|
+
@includes.uniq.select do |name|
|
|
204
|
+
association = resource.reflect_on_association(name)
|
|
205
|
+
next false if association.nil? || SchemaUtils.polymorphic?(association)
|
|
206
|
+
next false unless PRELOADABLE_CROSS_DATABASE_MACROS.include?(association.macro)
|
|
207
|
+
|
|
208
|
+
separate_database?(resource, association) && instance_dependent_hop(association).nil?
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Same version split as preload_polymorphic_associations. Nothing reads the loaders back here:
|
|
213
|
+
# a plain relation lands in the association cache, where the serializer finds it.
|
|
214
|
+
def preload_cross_database_associations(records, associations)
|
|
215
|
+
return if associations.empty? || records.empty?
|
|
216
|
+
|
|
217
|
+
associations = associations.reject { |name| missing_preload_key?(records, name) }
|
|
218
|
+
return if associations.empty?
|
|
219
|
+
|
|
220
|
+
if Rails::VERSION::MAJOR >= 7
|
|
221
|
+
ActiveRecord::Associations::Preloader.new(records: records, associations: associations).call
|
|
222
|
+
else
|
|
223
|
+
ActiveRecord::Associations::Preloader.new.preload(records, associations)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# A missing key raises while resolving the query, out of MissingAttributeValve's reach, and
|
|
228
|
+
# takes down the whole list rather than the one field (PRD-1316 from the other direction).
|
|
229
|
+
# Not only a belongs_to: a has_one reads its owner key off this row too when it declares a
|
|
230
|
+
# primary_key of its own. Per record class, not projected_resource, because that is how the
|
|
231
|
+
# Preloader resolves the reflection — one record per class is enough, the select being shared.
|
|
232
|
+
def missing_preload_key?(records, association_name)
|
|
233
|
+
records.group_by(&:class).any? do |klass, klass_records|
|
|
234
|
+
association = klass._reflect_on_association(association_name)
|
|
235
|
+
next false if association.nil?
|
|
236
|
+
|
|
237
|
+
missing = preload_owner_keys(association).reject { |key| klass_records.first.has_attribute?(key) }
|
|
238
|
+
next false if missing.empty?
|
|
239
|
+
|
|
240
|
+
warn_cross_database_preload_skipped(association_name, missing)
|
|
241
|
+
true
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def warn_cross_database_preload_skipped(association_name, missing_keys)
|
|
246
|
+
reason = "its \"#{missing_keys.join('", "')}\" key is not in the projected select"
|
|
247
|
+
return unless PRELOAD_SKIPS_WARNED.add?([@collection&.name, association_name, reason])
|
|
248
|
+
|
|
249
|
+
FOREST_LOGGER.warn "The \"#{association_name}\" relation of the \"#{@collection&.name}\" " \
|
|
250
|
+
"collection lives in another database and cannot be preloaded (#{reason}) — it falls back " \
|
|
251
|
+
'to the lazy load, which cannot read that key either and resolves to a null relation.'
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# The same question as missing_preload_key?, asked of a relation rather than of a page. A
|
|
255
|
+
# preload attached to a relation resolves after this method returns — per batch for an export,
|
|
256
|
+
# on #load for a list — so there is no record to test the key against, only the select the
|
|
257
|
+
# relation already carries. An empty one is SELECT *, which can never be missing anything; a
|
|
258
|
+
# narrowed one (a segment scope's .select, a default_scope's) can, and reading a key that is
|
|
259
|
+
# not there raises while resolving the query, out of MissingAttributeValve's reach, taking
|
|
260
|
+
# down the whole export where the lazy load it replaces degraded to a null relation.
|
|
261
|
+
def selectable_preloads(records, names)
|
|
262
|
+
return names if names.empty? || !records.respond_to?(:select_values)
|
|
263
|
+
return names if records.select_values.empty?
|
|
264
|
+
|
|
265
|
+
selected = selected_column_names(records)
|
|
266
|
+
return names if selected.include?('*')
|
|
267
|
+
|
|
268
|
+
names.reject do |name|
|
|
269
|
+
association = projected_resource.reflect_on_association(name)
|
|
270
|
+
next false if association.nil?
|
|
271
|
+
|
|
272
|
+
missing = preload_owner_keys(association).reject { |key| selected.include?(key.to_s) }
|
|
273
|
+
next false if missing.empty?
|
|
274
|
+
|
|
275
|
+
warn_unselected_preload_key(name, missing)
|
|
276
|
+
true
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Only a plain column reference can be read back off a select.
|
|
281
|
+
PLAIN_SELECT_REFERENCE = /\A(?:"?(?<table>\w+)"?\.)?"?(?<column>\w+|\*)"?\z/
|
|
282
|
+
|
|
283
|
+
def selected_column_names(records)
|
|
284
|
+
table = projected_resource.table_name
|
|
285
|
+
|
|
286
|
+
records.select_values.each_with_object(Set.new) do |value, names|
|
|
287
|
+
select_references(value).each do |reference|
|
|
288
|
+
match = PLAIN_SELECT_REFERENCE.match(reference)
|
|
289
|
+
next if match.nil? || (match[:table] && match[:table] != table)
|
|
290
|
+
|
|
291
|
+
names << match[:column]
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# One select_value can name several columns, which splitting on commas recovers — but only
|
|
297
|
+
# while no parenthesis is in play: `COALESCE(uri, driver_id, name) AS x` would otherwise read
|
|
298
|
+
# as naming driver_id, and the preload that lets through raises on a column the row does not
|
|
299
|
+
# carry, which is the failure this guard exists to prevent. An expression names nothing here,
|
|
300
|
+
# so the preload is skipped and the relation left to the lazy load — the safe way to be wrong.
|
|
301
|
+
#
|
|
302
|
+
# SqlLiteral is a String, and is meant to be read like one. An Arel attribute is not, and
|
|
303
|
+
# carries its table and column apart, `products.*` included.
|
|
304
|
+
def select_references(value)
|
|
305
|
+
case value
|
|
306
|
+
when String, Symbol
|
|
307
|
+
text = value.to_s
|
|
308
|
+
text.include?('(') ? [] : text.split(',').map(&:strip)
|
|
309
|
+
when Arel::Attributes::Attribute
|
|
310
|
+
value.relation.respond_to?(:name) ? ["#{value.relation.name}.#{value.name}"] : []
|
|
311
|
+
else
|
|
312
|
+
[]
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Re-checks the preloads a relation already carries, for the callers that attach them before
|
|
317
|
+
# the select is final — HasManyGetter builds its query in prepare_query and only projects in
|
|
318
|
+
# #perform. Judging the intermediate select drops preloads apply_projection was about to make
|
|
319
|
+
# safe: inherited_load_columns reads preload_values and selects their keys precisely because
|
|
320
|
+
# they are already attached. So the question is asked here, where the select is the one the
|
|
321
|
+
# query will run, and #preload only ever adds — removing one means rebuilding the relation.
|
|
322
|
+
def drop_unselected_preloads(records)
|
|
323
|
+
values = records.preload_values
|
|
324
|
+
return records if values.empty?
|
|
325
|
+
|
|
326
|
+
kept_names = selectable_preloads(records, association_names(values))
|
|
327
|
+
kept = values.select do |value|
|
|
328
|
+
names = value.is_a?(Hash) ? value.keys : [value]
|
|
329
|
+
names.all? { |name| kept_names.include?(name.to_sym) }
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
kept.size == values.size ? records : records.except(:preload).preload(kept)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def warn_unselected_preload_key(association_name, missing_keys)
|
|
336
|
+
reason = "its \"#{missing_keys.join('", "')}\" key is not in the query's select"
|
|
337
|
+
return unless PRELOAD_SKIPS_WARNED.add?([@collection&.name, association_name, reason])
|
|
338
|
+
|
|
339
|
+
FOREST_LOGGER.warn "The \"#{association_name}\" relation of the \"#{@collection&.name}\" " \
|
|
340
|
+
"collection cannot be preloaded (#{reason}) — it falls back to the lazy load, which reads " \
|
|
341
|
+
'it once per record where that key is on the row and resolves to a null relation where ' \
|
|
342
|
+
'it is not.'
|
|
343
|
+
end
|
|
344
|
+
|
|
195
345
|
# records_by_owner's keys are the exact objects the Preloader was given, not copies — no need
|
|
196
346
|
# to re-find them by id, which would also mis-assign on a nil or duplicate id (composite
|
|
197
347
|
# primary keys are supported elsewhere in this gem).
|
|
@@ -364,6 +514,19 @@ module ForestLiana
|
|
|
364
514
|
select_foreign_keys(select, projected_resource, association, joined?(association, joined_relations))
|
|
365
515
|
end
|
|
366
516
|
|
|
517
|
+
# A cross-database to-one relation is never joined, so select_foreign_keys names nothing
|
|
518
|
+
# owner-side for the has_one half of it — and the preloader reads that key off this row.
|
|
519
|
+
# Without it here the guard in preload_cross_database_associations fires on every page and
|
|
520
|
+
# the relation keeps being read once per record, which is the N+1 this file removes. Off
|
|
521
|
+
# @includes for the same reason as the polymorphic loop above: the preload runs over all of
|
|
522
|
+
# it, not only the requested subset.
|
|
523
|
+
cross_database_associations(projected_resource).each do |name|
|
|
524
|
+
association = projected_resource.reflect_on_association(name)
|
|
525
|
+
preload_owner_keys(association).each do |key|
|
|
526
|
+
select << "#{projected_resource.table_name}.#{key}" if column?(projected_resource, key)
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
367
530
|
@field_names_requested.each do |path|
|
|
368
531
|
association = get_one_association(path)
|
|
369
532
|
if association
|
|
@@ -85,11 +85,15 @@ module ForestLiana
|
|
|
85
85
|
# batch never calls #perform and only reads ids, so it falls back to the plain
|
|
86
86
|
# @base_records_for_batch, which needs no preload.
|
|
87
87
|
def query_for_batch
|
|
88
|
-
@
|
|
88
|
+
return @base_records_for_batch unless @unprojected_records
|
|
89
|
+
|
|
90
|
+
drop_unselected_preloads(apply_smart_field_preloads(@unprojected_records))
|
|
89
91
|
end
|
|
90
92
|
|
|
93
|
+
# The preloads were attached in prepare_query, before #perform projected: this is the first
|
|
94
|
+
# point where the select is the one the query will run.
|
|
91
95
|
def records
|
|
92
|
-
records = @records.limit(limit).offset(offset)
|
|
96
|
+
records = drop_unselected_preloads(@records).limit(limit).offset(offset)
|
|
93
97
|
polymorphic_associations, = analyze_associations(model_association)
|
|
94
98
|
|
|
95
99
|
# Left a Relation (not resolved yet) when there is nothing to preload - some callers still
|
|
@@ -222,9 +226,10 @@ module ForestLiana
|
|
|
222
226
|
# already routed those (and cross-DB ones) into `preload_loads`, so `move_to_preload` is
|
|
223
227
|
# always safe to preload; only `preload_loads` needs the Rails 7+ gate.
|
|
224
228
|
result = result.preload(move_to_preload)
|
|
225
|
-
result = result.preload(preload_loads) if Rails::VERSION::MAJOR >= 7
|
|
226
229
|
|
|
227
|
-
|
|
230
|
+
# Of the two shapes in preload_loads, 6.1's preloader only refuses the instance-dependent
|
|
231
|
+
# ones — gating both left it reading a cross-database relation once per row for nothing.
|
|
232
|
+
result.preload(Rails::VERSION::MAJOR >= 7 ? preload_loads : cross_database_associations(resource))
|
|
228
233
|
end
|
|
229
234
|
|
|
230
235
|
# Association names (symbols) whose JOIN the current request actually needs, so they must
|
|
@@ -75,15 +75,25 @@ module ForestLiana
|
|
|
75
75
|
# See HasManyGetter#query_for_batch: same dual-use split between a CSV export (which calls
|
|
76
76
|
# #perform first, so this picks up its smart-field preload) and a bulk "select all" batch
|
|
77
77
|
# (which never does, per initialize_resources_getter below, and only reads ids).
|
|
78
|
+
# On the relation, not a page: find_in_batches then resolves it per batch, so the key guard
|
|
79
|
+
# reads the select rather than a row. "Unprojected" only means this class did not narrow it —
|
|
80
|
+
# a segment whose scope calls .select narrows it before prepare_query ever returns.
|
|
78
81
|
def query_for_batch
|
|
79
|
-
@
|
|
82
|
+
return @base_records_for_batch unless @unprojected_records
|
|
83
|
+
|
|
84
|
+
records = apply_smart_field_preloads(@unprojected_records)
|
|
85
|
+
cross_database = selectable_preloads(records, cross_database_associations(@resource))
|
|
86
|
+
|
|
87
|
+
cross_database.empty? ? records : records.preload(cross_database)
|
|
80
88
|
end
|
|
81
89
|
|
|
82
90
|
def records
|
|
83
91
|
records = @records.offset(offset).limit(limit).to_a
|
|
84
|
-
polymorphic_association,
|
|
92
|
+
polymorphic_association, = analyze_associations(@resource)
|
|
85
93
|
|
|
86
94
|
preload_polymorphic_associations(records, polymorphic_association)
|
|
95
|
+
# On the page, not @records: count and query_for_batch build off the same relation.
|
|
96
|
+
preload_cross_database_associations(records, cross_database_associations(@resource))
|
|
87
97
|
|
|
88
98
|
records
|
|
89
99
|
end
|
|
@@ -130,7 +130,7 @@ module ForestLiana
|
|
|
130
130
|
def self.disable_filter_and_sort_if_cross_db!(opts, name, collection_name)
|
|
131
131
|
return unless opts[:reference]
|
|
132
132
|
|
|
133
|
-
assoc_name =
|
|
133
|
+
assoc_name = name.to_sym
|
|
134
134
|
model = find_model_from_collection_name(collection_name)
|
|
135
135
|
return unless model
|
|
136
136
|
|
data/lib/forest_liana/version.rb
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
1
|
class Car < GarageRecord
|
|
2
2
|
belongs_to :driver
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
# Cross-database, keyed on a column that is not Driver's primary key.
|
|
5
|
+
belongs_to :pilot, class_name: 'Driver', primary_key: :firstname, foreign_key: :model,
|
|
6
|
+
optional: true
|
|
7
|
+
|
|
8
|
+
# The same, plus a scope: the preloader applies it and a bare find_by does not, so the
|
|
9
|
+
# serializer's two branches only agree once the fallback is aligned on the preloader.
|
|
10
|
+
belongs_to :active_pilot, -> { where.not(firstname: 'retired') }, class_name: 'Driver',
|
|
11
|
+
primary_key: :firstname, foreign_key: :model, optional: true
|
|
12
|
+
end
|
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
class Manufacturer < ApplicationRecord
|
|
2
2
|
has_many :products
|
|
3
|
+
|
|
4
|
+
# A scope narrowing the select, which HasManyGetter attaches its preload to before
|
|
5
|
+
# apply_projection widens it — the select the guard must not judge.
|
|
6
|
+
has_many :narrowed_products, -> { select(:id, :name, :manufacturer_id) }, class_name: 'Product'
|
|
7
|
+
|
|
8
|
+
# Cross-database, and named after neither its target model nor anything else declared here.
|
|
9
|
+
belongs_to :chief, class_name: 'Driver', foreign_key: :name, primary_key: :firstname,
|
|
10
|
+
optional: true
|
|
3
11
|
end
|
|
@@ -2,5 +2,16 @@ class Product < ApplicationRecord
|
|
|
2
2
|
belongs_to :manufacturer
|
|
3
3
|
belongs_to :driver, optional: true
|
|
4
4
|
|
|
5
|
+
# Same database, but the same shape the serializer intercepts: a primary_key that is not the
|
|
6
|
+
# target's, plus a scope. Joined rather than preloaded, so it pins that the intercept is about
|
|
7
|
+
# the declared key, not about crossing a database. On a column of its own: a foreign key is not
|
|
8
|
+
# a serialized attribute, so keying this on `name` would take `name` off every Product payload
|
|
9
|
+
# in the suite.
|
|
10
|
+
belongs_to :maker, -> { where.not(name: 'retired') }, class_name: 'Manufacturer',
|
|
11
|
+
primary_key: :name, foreign_key: :maker_name, optional: true
|
|
12
|
+
|
|
13
|
+
# What a segment scope calling .select does to a relation before any getter sees it.
|
|
14
|
+
scope :narrowed_select, -> { select(:id, :name) }
|
|
15
|
+
|
|
5
16
|
validates :uri, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp }
|
|
6
17
|
end
|
data/spec/dummy/db/schema.rb
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
#
|
|
11
11
|
# It's strongly recommended that you check this file into your version control system.
|
|
12
12
|
|
|
13
|
-
ActiveRecord::Schema.define(version:
|
|
13
|
+
ActiveRecord::Schema.define(version: 2026_09_24_120000) do
|
|
14
14
|
|
|
15
15
|
create_table "addresses", force: :cascade do |t|
|
|
16
16
|
t.string "line1"
|
|
@@ -75,6 +75,7 @@ ActiveRecord::Schema.define(version: 2026_09_22_120000) do
|
|
|
75
75
|
t.string "name"
|
|
76
76
|
t.integer "manufacturer_id"
|
|
77
77
|
t.integer "driver_id"
|
|
78
|
+
t.string "maker_name"
|
|
78
79
|
t.index ["driver_id"], name: "index_products_on_driver_id"
|
|
79
80
|
t.index ["manufacturer_id"], name: "index_products_on_manufacturer_id"
|
|
80
81
|
end
|