forest_liana 9.22.0 → 9.22.1

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: be442492f5ecaafbff4d9d246c03634430ce0164159117f30a898cb730486090
4
+ data.tar.gz: 331dad7bbb30db9f6542adebf235ce1b8bf6e6f8abc96ef986b8fbb2c677aacd
5
5
  SHA512:
6
- metadata.gz: 7a952e5b05d2c1491f74b77a53e16012d89419af19040e6168488e000afba0651ebea4f3d7cb21b1ef3045598be8317c30930f7a1e6a681fd5458b8ce956672f
7
- data.tar.gz: 9884f0447c34ccb867abf7a2b41326229391e6a3c2689ad9884a15d09ac0ad067ba3a7ebfb30df13cab9502524ef29cd69de941779bfd3185cd385a893261960
6
+ metadata.gz: 8b01396fdeb47da3b36cba49666da16dd352ac8742a12cb83b84617b9c5e8529aaeaf500181c8b36cc4993aa090caaddf03e9c6b21ee3a17ddd47ab471b64e70
7
+ data.tar.gz: 707b4b922403fdefa05723d0fbdfadedc73bf7c73b59c1b7a9df6e8a55de61b6a43c2056ff50192063a385fc3e7a1202ac0770114c242409bf0a4499c84a46b0
@@ -473,10 +473,7 @@ module ForestLiana
473
473
  association = projected_resource.reflect_on_association(relation_path.relations.first.to_sym)
474
474
  next unless association
475
475
 
476
- preload_owner_keys(association).each do |key|
477
- select << "#{projected_resource.table_name}.#{key}" if column?(projected_resource, key)
478
- end
479
-
476
+ select_dependency_preload_keys(select, relation_path, joined_relations)
480
477
  select_foreign_keys(select, projected_resource, association, joined?(association, joined_relations))
481
478
 
482
479
  # A relation the caller also projects is built off the JOIN, and the preloader leaves an
@@ -528,6 +525,82 @@ module ForestLiana
528
525
  Array(reflection.join_foreign_key)
529
526
  end
530
527
 
528
+ # The same keys, but for every hop of a declared path this select can still reach, not only
529
+ # the first. The first hop reads the root row, which this select builds. The hop after it
530
+ # reads the rows that first hop produced — and when that hop is a relation the query *joins*,
531
+ # those rows are the narrowed ones built off the JOIN, so its key has to be named here too.
532
+ # It was not, and the list answered 500 rather than the one field (PRD-1316).
533
+ def select_dependency_preload_keys(select, relation_path, joined_relations)
534
+ chains = flatten_dependency_hops(relation_path.relations)
535
+ return if chains.empty?
536
+
537
+ narrowed_hops(chains, joined_relations).each do |owner, reflection|
538
+ Array(reflection.join_foreign_key).each do |key|
539
+ select << "#{owner.table_name}.#{key}" if column?(owner, key)
540
+ end
541
+ end
542
+ end
543
+
544
+ # The leading hops whose owner row this query builds itself, narrowed, and whose key it
545
+ # therefore has to name.
546
+ #
547
+ # The first hop always counts: it reads the root row. One hop past a *joined* relation counts
548
+ # too, its owners being the narrowed rows the JOIN built rather than the whole ones a preload
549
+ # selects. Two relations can be the joined one, and either shape reaches here:
550
+ # - the chain's own first hop, which for a :through is the relation it goes through
551
+ # (`island` of `has_one :location, through: :island`): the preloader reuses the loaded
552
+ # association and reads the source key off it;
553
+ # - the first declared relation, when the request displays it: the preloader then leaves
554
+ # that whole declaration alone, and the next declared relation reads its key off the
555
+ # joined rows. For a plain relation both are the same hop; for a :through they are not,
556
+ # and the second was missed.
557
+ #
558
+ # Nothing past those: joined_relations only ever names root relations, so every later hop
559
+ # comes from a preload of its own, which selects whole rows. Every table named up to here is
560
+ # in the FROM clause — eager loading a :through joins the tables it goes through as well.
561
+ def narrowed_hops(chains, joined_relations)
562
+ declared, head = chains.first
563
+ hops = chains.flat_map(&:last)
564
+
565
+ count = joined?(head.first.last, joined_relations) ? 2 : 1
566
+ count = [count, head.size + 1].max if joined?(declared, joined_relations)
567
+
568
+ hops.first(count)
569
+ end
570
+
571
+ # The direct reflections the preloader really walks for a declared path, grouped by the
572
+ # relation that declared them and each paired with the model it reads its key off.
573
+ #
574
+ # A :through hop is not preloaded as one: Preloader::ThroughAssociation loads the through
575
+ # relation, then the source relation on those records. So the relation a path names can hide
576
+ # the one the query joins — `location:coordinates` walking Tree's `has_one :location, through:
577
+ # :island` really starts on the joined `island`, which the declaration never mentions.
578
+ def flatten_dependency_hops(relations)
579
+ model = projected_resource
580
+
581
+ relations.map do |name|
582
+ association = model.reflect_on_association(name.to_sym)
583
+ return [] if association.nil? || SchemaUtils.polymorphic?(association)
584
+
585
+ chain = [association, flatten_through_hops(model, association)]
586
+ model = association.klass
587
+ chain
588
+ end
589
+ # Same net as preload_skip_reason's, for the same shapes: a :through naming a hop that does
590
+ # not exist, or a class_name pointing at no model, answers NameError off #klass. Nothing is
591
+ # selected for such a path — skip_preload? drops it from the preload too, so there is no key
592
+ # left to select for.
593
+ rescue NameError, ActiveRecord::ActiveRecordError
594
+ []
595
+ end
596
+
597
+ def flatten_through_hops(owner, association)
598
+ return [[owner, association]] unless association.through_reflection?
599
+
600
+ flatten_through_hops(owner, association.through_reflection) +
601
+ flatten_through_hops(association.through_reflection.klass, association.source_reflection)
602
+ end
603
+
531
604
  def get_one_association(name)
532
605
  # Handle composite primary keys - name might be an Array
533
606
  name_sym = name.is_a?(Array) ? name : name.to_sym
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.22.0"
2
+ VERSION = "9.22.1"
3
3
  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
@@ -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 ]
@@ -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
@@ -399,6 +399,209 @@ describe 'SQL footprint of a front call', type: :request do
399
399
  end
400
400
  end
401
401
 
402
+ # PRD-1316. A relation the list also displays is built off the JOIN, with a select narrowed to
403
+ # the requested columns — and the preloader leaves an already-loaded association alone, so the
404
+ # next hop of the path reads its key off those narrowed rows. `users.name` was never selected:
405
+ # the whole list answered 500 `missing attribute: name`, not just the field.
406
+ #
407
+ # The multi-hop fixture above never caught it because its own second hop (Island has_one
408
+ # :location) keys on `isle.id`, and apply_column_aliases always emits a joined table's primary
409
+ # key. Only a non-primary key reveals the gap.
410
+ describe 'a list whose declared path goes through a relation the request also displays' do
411
+ let(:seed) do
412
+ seeded = 0
413
+ lambda do |n|
414
+ n.times do
415
+ seeded += 1
416
+ Tree.create!(name: "owner#{seeded}", owner: User.create!(name: "owner#{seeded}"))
417
+ end
418
+ end
419
+ end
420
+ let(:params) do
421
+ { fields: { 'Tree' => 'id,name,owner,owner_named_trees_count' }, page: page,
422
+ searchExtended: '0', sort: '-id', timezone: 'Europe/Paris' }
423
+ end
424
+
425
+ it 'selects the key the next hop reads off the joined row' do
426
+ result = footprint(seed: seed) do |rows|
427
+ get '/forest/Tree', params: params, headers: headers
428
+ expect(response).to have_http_status(200)
429
+ expect(listed_rows).to eq(rows)
430
+ end
431
+
432
+ expect(result.per_row_delta).to eq(0), -> { result.delta_report }
433
+ # The key rides along in the JOIN's own select: still one joined query, never a second
434
+ # SELECT on users and never a fallback to "users".*.
435
+ expect(join_count(result.grown, 'users')).to eq(1)
436
+ expect(selects_from(result.grown, 'users')).to be_empty
437
+ root = selects_from(result.grown, 'trees').first
438
+ expect(root).to include(column_ref('users', 'name'))
439
+ expect(root).not_to include(column_ref('users', 'title'))
440
+ end
441
+
442
+ it 'selects it just as well when the request asks the relation for no column of its own' do
443
+ seed.call(3)
444
+
445
+ get '/forest/Tree', params: params.merge(fields: params[:fields].merge('owner' => 'id')),
446
+ headers: headers
447
+
448
+ expect(response).to have_http_status(200)
449
+ counts = JSON.parse(response.body)['data'].map { |row| row['attributes']['owner_named_trees_count'] }
450
+ expect(counts).to all(eq(1))
451
+ end
452
+
453
+ it 'reaches the far end of the path' do
454
+ seed.call(3)
455
+
456
+ get '/forest/Tree', params: params, headers: headers
457
+
458
+ expect(response).to have_http_status(200)
459
+ counts = JSON.parse(response.body)['data'].map { |row| row['attributes']['owner_named_trees_count'] }
460
+ expect(counts).to all(eq(1))
461
+ end
462
+ end
463
+
464
+ # The same shape declared the way the ticket declared it: the path names the :through relation
465
+ # alone, and the relation the query joins (`owner`) is the hop it goes through — a hop the
466
+ # declaration never mentions.
467
+ describe 'a list whose declared :through hides the joined hop' do
468
+ let(:seed) do
469
+ seeded = 0
470
+ lambda do |n|
471
+ n.times do
472
+ seeded += 1
473
+ Tree.create!(name: "owner#{seeded}", owner: User.create!(name: "owner#{seeded}"))
474
+ end
475
+ end
476
+ end
477
+ let(:params) do
478
+ { fields: { 'Tree' => 'id,name,owner,owner_named_tree_names', 'owner' => 'id' }, page: page,
479
+ searchExtended: '0', sort: '-id', timezone: 'Europe/Paris' }
480
+ end
481
+
482
+ it 'selects the key the source hop reads off the joined through row' do
483
+ result = footprint(seed: seed) do |rows|
484
+ get '/forest/Tree', params: params, headers: headers
485
+ expect(response).to have_http_status(200)
486
+ expect(listed_rows).to eq(rows)
487
+ end
488
+
489
+ expect(result.per_row_delta).to eq(0), -> { result.delta_report }
490
+ expect(join_count(result.grown, 'users')).to eq(1)
491
+ # As above: the key rides along in the JOIN's own select, and the narrowing survives —
492
+ # giving up and selecting the whole joined row would serve the same page just as green.
493
+ expect(selects_from(result.grown, 'users')).to be_empty
494
+ root = selects_from(result.grown, 'trees').first
495
+ expect(root).to include(column_ref('users', 'name'))
496
+ expect(root).not_to include(column_ref('users', 'title'))
497
+ end
498
+
499
+ it 'reaches the far end of the through' do
500
+ seed.call(3)
501
+
502
+ get '/forest/Tree', params: params, headers: headers
503
+
504
+ expect(response).to have_http_status(200)
505
+ names = JSON.parse(response.body)['data'].map { |row| row['attributes']['owner_named_tree_names'] }
506
+ expect(names).to all(match(/\Aowner\d+\z/))
507
+ end
508
+ end
509
+
510
+ # PRD-1316, the half a fixture keyed on `name` cannot see: `join_foreign_key` is what the
511
+ # preloader reads off the owner row, and `foreign_key` is not the same column. Both answer
512
+ # `name` for User#trees_by_name, and every other hop of the suite where they differ keys on
513
+ # `id`, which apply_column_aliases emits for a joined table anyway — so reading the wrong one
514
+ # of the two costs nothing anywhere else, and the fix would be free to rot.
515
+ #
516
+ # User#trees_by_title holds them apart: `title` is read, `age` is the foreign key, and `age`
517
+ # is no column of users at all, so the wrong one selects nothing and the list 500s again.
518
+ describe 'a list whose joined hop is keyed on a column its foreign key is not' do
519
+ let(:seed) do
520
+ seeded = 0
521
+ lambda do |n|
522
+ n.times do
523
+ seeded += 1
524
+ Tree.create!(name: 'tree', owner: User.create!(name: "owner#{seeded}"))
525
+ end
526
+ end
527
+ end
528
+ let(:params) do
529
+ { fields: { 'Tree' => 'id,name,owner,owner_titled_trees_count', 'owner' => 'id' }, page: page,
530
+ searchExtended: '0', sort: '-id', timezone: 'Europe/Paris' }
531
+ end
532
+
533
+ it 'selects the key the preloader reads, not the association foreign key' do
534
+ result = footprint(seed: seed) do |rows|
535
+ get '/forest/Tree', params: params, headers: headers
536
+ expect(response).to have_http_status(200)
537
+ expect(listed_rows).to eq(rows)
538
+ end
539
+
540
+ expect(result.per_row_delta).to eq(0), -> { result.delta_report }
541
+ expect(join_count(result.grown, 'users')).to eq(1)
542
+ expect(selects_from(result.grown, 'users')).to be_empty
543
+ root = selects_from(result.grown, 'trees').first
544
+ expect(root).to include(column_ref('users', 'title'))
545
+ expect(root).not_to include(column_ref('users', 'name'))
546
+ end
547
+ end
548
+
549
+ # The path does not stop at the displayed relation but carries on past it. The preloader leaves
550
+ # that whole declaration alone — it is already loaded off the JOIN — so the hop *after* it reads
551
+ # its key off those narrowed rows. Where the relation is a :through, the hop the chain starts
552
+ # with (`island`) is not the one the request displays (`location`), and looking only at the
553
+ # first named nothing as joined at all: 500 on `locations.coordinates`.
554
+ describe 'a list whose declared path carries on past a joined :through' do
555
+ let(:seed) do
556
+ seeded = 0
557
+ lambda do |n|
558
+ n.times do
559
+ seeded += 1
560
+ island = Island.create!(name: "isle#{seeded}")
561
+ Location.create!(island: island, coordinates: "tree#{seeded}")
562
+ Tree.create!(name: "tree#{seeded}", island: island, owner: User.create!(name: 'owner'))
563
+ end
564
+ end
565
+ end
566
+ let(:params) do
567
+ { fields: { 'Tree' => 'id,name,location,location_trees_count', 'location' => 'id' },
568
+ page: page, searchExtended: '0', sort: '-id', timezone: 'Europe/Paris' }
569
+ end
570
+
571
+ it 'selects the key the hop after the whole declaration reads' do
572
+ result = footprint(seed: seed) do |rows|
573
+ get '/forest/Tree', params: params, headers: headers
574
+ expect(response).to have_http_status(200)
575
+ expect(listed_rows).to eq(rows)
576
+ end
577
+
578
+ # Rails 6.1's Preloader reuses the association the JOIN already loaded and the page costs
579
+ # nothing extra. From Rails 7 it re-walks the :through instead — only `location` is loaded
580
+ # on these rows, never the `island` it goes through — and builds Locations of its own that
581
+ # `object.location`, still the JOIN's row, never reads: the preload is wasted and the field
582
+ # falls back to one read per row. Orthogonal to the key selected here, which is what makes
583
+ # that read answer the right thing rather than nothing; pinned so it stays visible.
584
+ expect(result.per_row_delta(table: 'trees')).to eq(Rails::VERSION::MAJOR >= 7 ? 1 : 0),
585
+ -> { result.delta_report(table: 'trees') }
586
+ # The key rides along in the JOIN's own select, which stays narrowed: still one joined
587
+ # query, and never a fallback to "locations".*.
588
+ expect(join_count(result.grown, 'locations')).to eq(1)
589
+ root = selects_from(result.grown, 'trees').first
590
+ expect(root).to include(column_ref('locations', 'coordinates'))
591
+ expect(root).not_to include(column_ref('locations', 'updated_at'))
592
+ end
593
+
594
+ it 'reaches the far end of the path' do
595
+ seed.call(3)
596
+
597
+ get '/forest/Tree', params: params, headers: headers
598
+
599
+ expect(response).to have_http_status(200)
600
+ counts = JSON.parse(response.body)['data'].map { |row| row['attributes']['location_trees_count'] }
601
+ expect(counts).to all(eq(1))
602
+ end
603
+ end
604
+
402
605
  describe 'a list projecting a smart field that walks an undeclared relation' do
403
606
  let(:seed) do
404
607
  lambda do |n|
@@ -172,6 +172,44 @@ module ForestLiana
172
172
  end
173
173
  end
174
174
 
175
+ # The same two shapes, on the other half of the mechanism: skip_preload? keeps them out of the
176
+ # preload, and this keeps them out of the select. Neither guard can be reached through a
177
+ # collection of the dummy — SmartFieldDependencies.validate! strips such a declaration at boot
178
+ # — so they are exercised the way a collection built outside that pass would reach them.
179
+ describe '#select_dependency_preload_keys' do
180
+ def keys_for(resource, relations, column: 'name', joined: nil)
181
+ getter.instance_variable_set(:@resource, resource)
182
+ select = []
183
+ getter.send(:select_dependency_preload_keys, select,
184
+ SmartFieldDependencies::RelationPath.new(relations, column), joined)
185
+ select
186
+ end
187
+
188
+ it 'selects the key of every hop it can still reach' do
189
+ expect(keys_for(Tree, %w[owner trees_by_name], joined: [:owner]))
190
+ .to eq(['trees.owner_id', 'users.name'])
191
+ end
192
+
193
+ it 'selects nothing for a path naming a relation that does not exist' do
194
+ expect(keys_for(Tree, %w[nowhere])).to eq([])
195
+ end
196
+
197
+ it 'selects nothing for a path crossing a polymorphic relation' do
198
+ expect(keys_for(Address, %w[addressable])).to eq([])
199
+ end
200
+
201
+ # A class_name: pointing at no model, or a :through naming a hop that is not there: both
202
+ # answer NameError off #klass, which nothing here would otherwise catch.
203
+ it 'selects nothing for a path whose target model does not resolve' do
204
+ reflection = Tree.reflect_on_association(:island)
205
+ allow(reflection).to receive(:klass).and_raise(NameError, 'uninitialized constant Nowhere')
206
+ allow(Tree).to receive(:reflect_on_association).and_call_original
207
+ allow(Tree).to receive(:reflect_on_association).with(:island).and_return(reflection)
208
+
209
+ expect(keys_for(Tree, %w[island location], column: 'coordinates')).to eq([])
210
+ end
211
+ end
212
+
175
213
  # Every skip above silently reinstates the N+1 the declaration was written to remove, and
176
214
  # what breaks a declaration that used to work is usually a change made elsewhere, long after
177
215
  # anyone verified it. The log line is the only thing that says so.
@@ -93,12 +93,17 @@ module ForestLiana
93
93
  "location",
94
94
  "name",
95
95
  "owner",
96
+ "owner_named_trees",
96
97
  "updated_at",
97
98
  "name_with_age",
98
99
  "owner_name",
99
100
  "owner_name_declared",
100
101
  "island_coordinates",
101
102
  "through_coordinates",
103
+ "owner_named_trees_count",
104
+ "owner_named_tree_names",
105
+ "owner_titled_trees_count",
106
+ "location_trees_count",
102
107
  "smart_owner"
103
108
  ]
104
109
  )
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_liana
3
3
  version: !ruby/object:Gem::Version
4
- version: 9.22.0
4
+ version: 9.22.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sandro Munda
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-23 00:00:00.000000000 Z
11
+ date: 2026-09-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails