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 +4 -4
- data/app/services/forest_liana/base_getter.rb +77 -4
- data/lib/forest_liana/version.rb +1 -1
- data/spec/dummy/app/models/location.rb +5 -0
- data/spec/dummy/app/models/tree.rb +4 -0
- data/spec/dummy/app/models/user.rb +11 -0
- 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 +203 -0
- data/spec/services/forest_liana/base_getter_spec.rb +38 -0
- data/spec/services/forest_liana/schema_adapter_spec.rb +5 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be442492f5ecaafbff4d9d246c03634430ce0164159117f30a898cb730486090
|
|
4
|
+
data.tar.gz: 331dad7bbb30db9f6542adebf235ce1b8bf6e6f8abc96ef986b8fbb2c677aacd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
data/lib/forest_liana/version.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
11
|
+
date: 2026-09-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|