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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6042deb02adc0ae0d73c97cac99e2abdf0807cdf759cfd0cde116bf94fdc3dff
4
- data.tar.gz: 351c37c6fd6cfc17e9c4de3c8f760b84527ca7c1d7a0e2df6976b67b8d549329
3
+ metadata.gz: 2519878c68b98965bc3b8e31cf9ae9e8253f9d215cf724d99233e55cb37ab8c5
4
+ data.tar.gz: ae92f459f29ab022290ad09f33e9d1cec654d86fce2784cb425e8c8924b87e59
5
5
  SHA512:
6
- metadata.gz: 7a952e5b05d2c1491f74b77a53e16012d89419af19040e6168488e000afba0651ebea4f3d7cb21b1ef3045598be8317c30930f7a1e6a681fd5458b8ce956672f
7
- data.tar.gz: 9884f0447c34ccb867abf7a2b41326229391e6a3c2689ad9884a15d09ac0ad067ba3a7ebfb30df13cab9502524ef29cd69de941779bfd3185cd385a893261960
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
- relation.klass.find_by(reflection_primary_key => object.send(relation.foreign_key))
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
- result = result.preload(preload_loads) if Rails::VERSION::MAJOR >= 7 && force_preload
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
- preload_owner_keys(association).each do |key|
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
- @unprojected_records ? apply_smart_field_preloads(@unprojected_records) : @base_records_for_batch
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
- result
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
- @unprojected_records ? apply_smart_field_preloads(@unprojected_records) : @base_records_for_batch
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, preload_loads = analyze_associations(@resource)
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 = opts[:reference].split('.').first&.underscore&.to_sym || name
133
+ assoc_name = name.to_sym
134
134
  model = find_model_from_collection_name(collection_name)
135
135
  return unless model
136
136
 
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.22.0"
2
+ VERSION = "9.22.2"
3
3
  end
@@ -1,3 +1,12 @@
1
1
  class Car < GarageRecord
2
2
  belongs_to :driver
3
- end
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,6 @@
1
1
  class Driver < UserRecord
2
2
  has_one :car
3
- end
3
+
4
+ # The inverse of Car#pilot: the preload reads "firstname" off this row, and nothing projects it.
5
+ has_one :piloted_car, class_name: 'Car', primary_key: :firstname, foreign_key: :model
6
+ 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 ]
@@ -0,0 +1,5 @@
1
+ class AddMakerNameToProducts < ActiveRecord::Migration[6.0]
2
+ def change
3
+ add_column :products, :maker_name, :string
4
+ end
5
+ end
@@ -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: 2026_09_22_120000) do
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