forest_liana 9.22.0 → 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 +241 -5
- 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/location.rb +5 -0
- data/spec/dummy/app/models/manufacturer.rb +8 -0
- data/spec/dummy/app/models/product.rb +11 -0
- data/spec/dummy/app/models/tree.rb +4 -0
- data/spec/dummy/app/models/user.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/dummy/lib/forest_liana/collections/tree.rb +27 -0
- data/spec/requests/projection_header_spec.rb +27 -1
- data/spec/requests/query_footprint_spec.rb +553 -5
- data/spec/services/forest_liana/base_getter_spec.rb +319 -0
- data/spec/services/forest_liana/schema_adapter_spec.rb +5 -0
- data/spec/services/forest_liana/schema_utils_spec.rb +57 -0
- data/spec/services/forest_liana/serializer_factory_spec.rb +167 -0
- metadata +6 -2
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
|
|
@@ -473,10 +636,7 @@ module ForestLiana
|
|
|
473
636
|
association = projected_resource.reflect_on_association(relation_path.relations.first.to_sym)
|
|
474
637
|
next unless association
|
|
475
638
|
|
|
476
|
-
|
|
477
|
-
select << "#{projected_resource.table_name}.#{key}" if column?(projected_resource, key)
|
|
478
|
-
end
|
|
479
|
-
|
|
639
|
+
select_dependency_preload_keys(select, relation_path, joined_relations)
|
|
480
640
|
select_foreign_keys(select, projected_resource, association, joined?(association, joined_relations))
|
|
481
641
|
|
|
482
642
|
# A relation the caller also projects is built off the JOIN, and the preloader leaves an
|
|
@@ -528,6 +688,82 @@ module ForestLiana
|
|
|
528
688
|
Array(reflection.join_foreign_key)
|
|
529
689
|
end
|
|
530
690
|
|
|
691
|
+
# The same keys, but for every hop of a declared path this select can still reach, not only
|
|
692
|
+
# the first. The first hop reads the root row, which this select builds. The hop after it
|
|
693
|
+
# reads the rows that first hop produced — and when that hop is a relation the query *joins*,
|
|
694
|
+
# those rows are the narrowed ones built off the JOIN, so its key has to be named here too.
|
|
695
|
+
# It was not, and the list answered 500 rather than the one field (PRD-1316).
|
|
696
|
+
def select_dependency_preload_keys(select, relation_path, joined_relations)
|
|
697
|
+
chains = flatten_dependency_hops(relation_path.relations)
|
|
698
|
+
return if chains.empty?
|
|
699
|
+
|
|
700
|
+
narrowed_hops(chains, joined_relations).each do |owner, reflection|
|
|
701
|
+
Array(reflection.join_foreign_key).each do |key|
|
|
702
|
+
select << "#{owner.table_name}.#{key}" if column?(owner, key)
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
# The leading hops whose owner row this query builds itself, narrowed, and whose key it
|
|
708
|
+
# therefore has to name.
|
|
709
|
+
#
|
|
710
|
+
# The first hop always counts: it reads the root row. One hop past a *joined* relation counts
|
|
711
|
+
# too, its owners being the narrowed rows the JOIN built rather than the whole ones a preload
|
|
712
|
+
# selects. Two relations can be the joined one, and either shape reaches here:
|
|
713
|
+
# - the chain's own first hop, which for a :through is the relation it goes through
|
|
714
|
+
# (`island` of `has_one :location, through: :island`): the preloader reuses the loaded
|
|
715
|
+
# association and reads the source key off it;
|
|
716
|
+
# - the first declared relation, when the request displays it: the preloader then leaves
|
|
717
|
+
# that whole declaration alone, and the next declared relation reads its key off the
|
|
718
|
+
# joined rows. For a plain relation both are the same hop; for a :through they are not,
|
|
719
|
+
# and the second was missed.
|
|
720
|
+
#
|
|
721
|
+
# Nothing past those: joined_relations only ever names root relations, so every later hop
|
|
722
|
+
# comes from a preload of its own, which selects whole rows. Every table named up to here is
|
|
723
|
+
# in the FROM clause — eager loading a :through joins the tables it goes through as well.
|
|
724
|
+
def narrowed_hops(chains, joined_relations)
|
|
725
|
+
declared, head = chains.first
|
|
726
|
+
hops = chains.flat_map(&:last)
|
|
727
|
+
|
|
728
|
+
count = joined?(head.first.last, joined_relations) ? 2 : 1
|
|
729
|
+
count = [count, head.size + 1].max if joined?(declared, joined_relations)
|
|
730
|
+
|
|
731
|
+
hops.first(count)
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
# The direct reflections the preloader really walks for a declared path, grouped by the
|
|
735
|
+
# relation that declared them and each paired with the model it reads its key off.
|
|
736
|
+
#
|
|
737
|
+
# A :through hop is not preloaded as one: Preloader::ThroughAssociation loads the through
|
|
738
|
+
# relation, then the source relation on those records. So the relation a path names can hide
|
|
739
|
+
# the one the query joins — `location:coordinates` walking Tree's `has_one :location, through:
|
|
740
|
+
# :island` really starts on the joined `island`, which the declaration never mentions.
|
|
741
|
+
def flatten_dependency_hops(relations)
|
|
742
|
+
model = projected_resource
|
|
743
|
+
|
|
744
|
+
relations.map do |name|
|
|
745
|
+
association = model.reflect_on_association(name.to_sym)
|
|
746
|
+
return [] if association.nil? || SchemaUtils.polymorphic?(association)
|
|
747
|
+
|
|
748
|
+
chain = [association, flatten_through_hops(model, association)]
|
|
749
|
+
model = association.klass
|
|
750
|
+
chain
|
|
751
|
+
end
|
|
752
|
+
# Same net as preload_skip_reason's, for the same shapes: a :through naming a hop that does
|
|
753
|
+
# not exist, or a class_name pointing at no model, answers NameError off #klass. Nothing is
|
|
754
|
+
# selected for such a path — skip_preload? drops it from the preload too, so there is no key
|
|
755
|
+
# left to select for.
|
|
756
|
+
rescue NameError, ActiveRecord::ActiveRecordError
|
|
757
|
+
[]
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def flatten_through_hops(owner, association)
|
|
761
|
+
return [[owner, association]] unless association.through_reflection?
|
|
762
|
+
|
|
763
|
+
flatten_through_hops(owner, association.through_reflection) +
|
|
764
|
+
flatten_through_hops(association.through_reflection.klass, association.source_reflection)
|
|
765
|
+
end
|
|
766
|
+
|
|
531
767
|
def get_one_association(name)
|
|
532
768
|
# Handle composite primary keys - name might be an Array
|
|
533
769
|
name_sym = name.is_a?(Array) ? name : name.to_sym
|
|
@@ -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,8 @@
|
|
|
1
1
|
class Location < ActiveRecord::Base
|
|
2
2
|
belongs_to :island
|
|
3
|
+
|
|
4
|
+
# Reached as the hop *after* Tree#location, a has_one :through the list displays: the row it
|
|
5
|
+
# reads `locations.coordinates` off is the narrowed one that relation's own JOIN built
|
|
6
|
+
# (query_footprint_spec.rb, PRD-1316).
|
|
7
|
+
has_many :trees_by_coordinates, class_name: 'Tree', primary_key: 'coordinates', foreign_key: 'name'
|
|
3
8
|
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
|
|
@@ -9,4 +9,8 @@ class Tree < ActiveRecord::Base
|
|
|
9
9
|
optional: true
|
|
10
10
|
|
|
11
11
|
has_one :location, through: :island
|
|
12
|
+
|
|
13
|
+
# A :through whose hop is the displayed `belongs_to :owner`: a path naming this relation alone
|
|
14
|
+
# never names the relation the query joins, which is the shape PRD-1316 crashed on.
|
|
15
|
+
has_many :owner_named_trees, through: :owner, source: :trees_by_name
|
|
12
16
|
end
|
|
@@ -6,6 +6,17 @@ class User < ActiveRecord::Base
|
|
|
6
6
|
# The only has_and_belongs_to_many of the dummy.
|
|
7
7
|
has_and_belongs_to_many :favourite_trees, class_name: 'Tree', join_table: 'trees_users'
|
|
8
8
|
|
|
9
|
+
# Keyed on `name`, not on the primary key, like Owner#trees_by_name — but reached here as the
|
|
10
|
+
# *second* hop of a dependency path whose first hop the list also displays, so the row it reads
|
|
11
|
+
# `users.name` off is the narrowed one the JOIN built (query_footprint_spec.rb, PRD-1316).
|
|
12
|
+
has_many :trees_by_name, class_name: 'Tree', primary_key: 'name', foreign_key: 'name'
|
|
13
|
+
|
|
14
|
+
# The same second-hop shape, but with the two keys held apart: the preloader reads
|
|
15
|
+
# `users.title`, `foreign_key` answers `age`, and `age` is no column of users at all. The one
|
|
16
|
+
# relation of the dummy that tells join_foreign_key and foreign_key apart on a narrowed row —
|
|
17
|
+
# everywhere else they either agree or both land on the primary key a join emits anyway.
|
|
18
|
+
has_many :trees_by_title, class_name: 'Tree', primary_key: 'title', foreign_key: 'age'
|
|
19
|
+
|
|
9
20
|
# The keyword form is the only one Rails 6.1 knows; Rails 8 only keeps the positional one.
|
|
10
21
|
if Rails.gem_version >= Gem::Version.new('7.0')
|
|
11
22
|
enum :title, [ :king, :villager, :outlaw ]
|
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
|
|
@@ -37,6 +37,33 @@ class Forest::Tree
|
|
|
37
37
|
object.location&.coordinates
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# A multi-hop path whose first hop the list also displays as a column. The hop-2 preload reads
|
|
41
|
+
# `users.name` off the rows the JOIN built with a narrowed select — the fixture for "is the key
|
|
42
|
+
# a later hop reads selected on the table it is joined from" (query_footprint_spec.rb).
|
|
43
|
+
field :owner_named_trees_count, type: 'Number', dependencies: ['owner:trees_by_name:name'] do
|
|
44
|
+
object.owner.trees_by_name.size
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The same failure through a :through, where the joined hop is the one the path never names —
|
|
48
|
+
# PRD-1316's own declaration shape.
|
|
49
|
+
field :owner_named_tree_names, type: 'String', dependencies: ['owner_named_trees:name'] do
|
|
50
|
+
object.owner_named_trees.map(&:name).join(',')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The hop-2 key held apart from the hop's foreign key: `users.title` is what the preloader
|
|
54
|
+
# reads, `age` what foreign_key answers and no column of users. Reading the wrong one of the
|
|
55
|
+
# two selects nothing at all, and the list 500s again.
|
|
56
|
+
field :owner_titled_trees_count, type: 'Number', dependencies: ['owner:trees_by_title:name'] do
|
|
57
|
+
object.owner.trees_by_title.size
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A path that carries on *past* a displayed has_one :through: the preloader leaves the whole
|
|
61
|
+
# declaration alone and the next hop reads `locations.coordinates` off the rows that relation's
|
|
62
|
+
# JOIN built. The joined relation is then the one the path names, not the hop it goes through.
|
|
63
|
+
field :location_trees_count, type: 'Number', dependencies: ['location:trees_by_coordinates:name'] do
|
|
64
|
+
object.location&.trees_by_coordinates&.size
|
|
65
|
+
end
|
|
66
|
+
|
|
40
67
|
# A smart belongs_to: is_virtual with a reference, and no ActiveRecord reflection behind it —
|
|
41
68
|
# the fixture for everything that walks a getter's includes (has_many_getter_spec.rb,
|
|
42
69
|
# associations_spec.rb). Nothing else in the dummy declared one.
|
|
@@ -207,7 +207,7 @@ describe 'Requesting resources with the Forest-Projection header', :type => :req
|
|
|
207
207
|
expect(response.status).to eq 200
|
|
208
208
|
expect(body['data']['attributes']).to eq('id' => @user.id, 'name' => 'Michel')
|
|
209
209
|
expect(body['data']['relationships'].keys)
|
|
210
|
-
.to match_array %w[trees_owned trees_cut addresses favourite_trees smart_trees]
|
|
210
|
+
.to match_array %w[trees_owned trees_cut trees_by_name trees_by_title addresses favourite_trees smart_trees]
|
|
211
211
|
expect(body['data']['relationships']['trees_owned']['links']['related']['href'])
|
|
212
212
|
.to eq "/forest/User/#{@user.id}/relationships/trees_owned"
|
|
213
213
|
expect(body['data']['relationships']['favourite_trees']['links']['related']['href'])
|
|
@@ -578,6 +578,32 @@ describe 'Requesting resources with the Forest-Projection header', :type => :req
|
|
|
578
578
|
end
|
|
579
579
|
end
|
|
580
580
|
|
|
581
|
+
# PRD-1316. The hop after a joined one reads its key off the narrowed rows the JOIN built, so
|
|
582
|
+
# that key belongs to this select — `owner:trees_by_name:name` preloads Tree's owners' trees by
|
|
583
|
+
# `users.name`, which nothing else here would have selected.
|
|
584
|
+
describe 'on a dependency path whose first hop the request also projects' do
|
|
585
|
+
let(:getter) do
|
|
586
|
+
ForestLiana::ResourceGetter.new(
|
|
587
|
+
Tree,
|
|
588
|
+
ActionController::Parameters.new(
|
|
589
|
+
id: 1, fields: { 'Tree' => 'id,name,owner,owner_named_trees_count' }
|
|
590
|
+
),
|
|
591
|
+
nil
|
|
592
|
+
)
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
it 'selects the key the next hop reads, on the joined table' do
|
|
596
|
+
expect(getter.send(:compute_select_fields, [:owner])).to include('users.name')
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
it 'leaves it out when that hop is not joined, its table not being in the FROM clause' do
|
|
600
|
+
select = getter.send(:compute_select_fields, [])
|
|
601
|
+
|
|
602
|
+
expect(select).to include('trees.owner_id')
|
|
603
|
+
expect(select).not_to include('users.name')
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
581
607
|
# NOTICE: Only a bare column name can be table-qualified. An ordering expression would reach
|
|
582
608
|
# the SQL as table.LOWER(name) and raise.
|
|
583
609
|
describe 'on a collection ordered by an expression' do
|