forest_liana 9.20.10 → 9.20.11
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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a13f2892e518d41f6c0aae28e4f14397086cd58f19b25c4b1fa86b4fefeaf911
|
|
4
|
+
data.tar.gz: 76853d12f9eddbf87235f2a407814d3e23938a35f51c1e77a33f99ccdee0a5e7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ff3de870bcc72b16398b83be65bc2fb363b5274f17138fdd63532b93b34a38c1ed5959514642c16fad12a31c1040ed63039f614ec24e24281076049869d853a6
|
|
7
|
+
data.tar.gz: fe52ff22910b5e3d701de07cc26c60036c67ab735a2945cde9a0c2f76739b1a3c13062eaa4b6dcab7306a3968906914db707fac023ade714783e69d006fb4df3
|
|
@@ -121,5 +121,77 @@ module ForestLiana
|
|
|
121
121
|
def pagination?
|
|
122
122
|
@params[:page] && @params[:page][:number]
|
|
123
123
|
end
|
|
124
|
+
|
|
125
|
+
# Overrides BaseGetter#optimize_record_loading for the has-many relationship path.
|
|
126
|
+
#
|
|
127
|
+
# BaseGetter eager_loads every belongs_to/has_one/HABTM association of the target
|
|
128
|
+
# model in a single LEFT OUTER JOIN, and #records then applies LIMIT/OFFSET on top of
|
|
129
|
+
# it. When an association is declared `has_one` but is one-to-many in the data, Rails
|
|
130
|
+
# keeps it singular and does NOT switch to the limited-ids strategy, so LIMIT is applied
|
|
131
|
+
# directly to the row-multiplied JOIN output: a page collapses to fewer DISTINCT records
|
|
132
|
+
# than requested and records past the window are silently dropped (the count stays right
|
|
133
|
+
# because #count uses COUNT(DISTINCT id)).
|
|
134
|
+
#
|
|
135
|
+
# These associations are eager-loaded only to serialize the related-list columns, not
|
|
136
|
+
# for the query's WHERE/ORDER. So keep eager-loaded only the associations the current
|
|
137
|
+
# request needs a JOIN for (sort BY a relation column, or SearchQueryBuilder's extended
|
|
138
|
+
# search, which builds its OR conditions against the already-joined tables), and preload
|
|
139
|
+
# the remaining display-only associations (separate queries, no row multiplication).
|
|
140
|
+
# Filters on a relation column are unaffected: FiltersParser#apply_filters already calls
|
|
141
|
+
# `eager_load` for those associations itself, earlier in the pipeline (see #prepare_query),
|
|
142
|
+
# and that join composes fine with the one added here. LIMIT then applies to the
|
|
143
|
+
# un-multiplied base rows and returns the correct DISTINCT records.
|
|
144
|
+
def optimize_record_loading(resource, records, force_preload = true)
|
|
145
|
+
polymorphic, preload_loads = analyze_associations(resource)
|
|
146
|
+
display_includes = @includes.uniq - preload_loads - polymorphic - @optional_includes
|
|
147
|
+
|
|
148
|
+
keep_eager = display_includes & associations_to_keep_eager
|
|
149
|
+
move_to_preload = display_includes - keep_eager
|
|
150
|
+
|
|
151
|
+
result = records.eager_load(keep_eager)
|
|
152
|
+
return result unless force_preload
|
|
153
|
+
|
|
154
|
+
# NOTICE: mixing `eager_load` and `preload` in the same scope works fine on Rails 6 as
|
|
155
|
+
# long as the preloaded association isn't instance-dependent (see #567) — analyze_associations
|
|
156
|
+
# already routed those (and cross-DB ones) into `preload_loads`, so `move_to_preload` is
|
|
157
|
+
# always safe to preload; only `preload_loads` needs the Rails 7+ gate.
|
|
158
|
+
result = result.preload(move_to_preload)
|
|
159
|
+
result = result.preload(preload_loads) if Rails::VERSION::MAJOR >= 7
|
|
160
|
+
|
|
161
|
+
result
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Association names (symbols) whose JOIN the current request actually needs, so they must
|
|
165
|
+
# stay in `eager_load` rather than move to `preload`.
|
|
166
|
+
def associations_to_keep_eager
|
|
167
|
+
(associations_referenced_by_sort + associations_referenced_by_extended_search).uniq
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# @params[:sort] is a comma-separated string; a leading '-' means descending and a '.'
|
|
171
|
+
# references a relation (the part before the dot). e.g. '-owner.name' => :owner
|
|
172
|
+
def associations_referenced_by_sort
|
|
173
|
+
sort = @params[:sort]
|
|
174
|
+
return [] if sort.nil? || sort.to_s.empty?
|
|
175
|
+
|
|
176
|
+
sort.to_s.split(',').map do |field|
|
|
177
|
+
field = field.strip.sub(/\A-/, '')
|
|
178
|
+
field.include?('.') ? field.split('.').first.to_sym : nil
|
|
179
|
+
end.compact
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# SearchQueryBuilder#search_param, when `searchExtended=1`, adds `OR` conditions against
|
|
183
|
+
# every included one-association's columns and relies on its table already being joined.
|
|
184
|
+
# Mirror the associations it can reach so their JOIN stays eager instead of moving to
|
|
185
|
+
# preload (which never joins, on any Rails version).
|
|
186
|
+
def associations_referenced_by_extended_search
|
|
187
|
+
return [] unless @params[:search].present? && @params['searchExtended'].to_i == 1
|
|
188
|
+
|
|
189
|
+
target_model = model_association
|
|
190
|
+
includes_symbols = @includes.map(&:to_sym)
|
|
191
|
+
QueryHelper.get_one_association_names_symbol(target_model).select do |association_name|
|
|
192
|
+
includes_symbols.include?(association_name) &&
|
|
193
|
+
!SchemaUtils.polymorphic?(target_model.reflect_on_association(association_name))
|
|
194
|
+
end
|
|
195
|
+
end
|
|
124
196
|
end
|
|
125
197
|
end
|
data/lib/forest_liana/version.rb
CHANGED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
module ForestLiana
|
|
2
|
+
# Regression test for related-list pagination dropping records.
|
|
3
|
+
# See HasManyGetter#optimize_record_loading: a `has_one` association that actually
|
|
4
|
+
# resolves to MANY rows makes the eager_load JOIN multiply rows, so LIMIT used to
|
|
5
|
+
# collapse a page to fewer DISTINCT records than requested and silently drop the rest.
|
|
6
|
+
describe HasManyGetter, 'pagination with a has_one that is one-to-many in the data' do
|
|
7
|
+
let(:rendering_id) { 13 }
|
|
8
|
+
let(:user) { { 'id' => '1', 'rendering_id' => rendering_id } }
|
|
9
|
+
let(:scopes) { { 'scopes' => {}, 'team' => { 'id' => '1', 'name' => 'Operations' } } }
|
|
10
|
+
let(:association) { Island.reflect_on_association(:trees) }
|
|
11
|
+
|
|
12
|
+
let(:island) { Island.create!(name: 'sicily') }
|
|
13
|
+
let(:page_size) { 2 }
|
|
14
|
+
let(:params) do
|
|
15
|
+
{
|
|
16
|
+
id: island.id,
|
|
17
|
+
association_name: 'trees',
|
|
18
|
+
sort: 'id',
|
|
19
|
+
page: { size: page_size, number: 1 },
|
|
20
|
+
timezone: 'America/Nome'
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
subject { described_class.new(Island, association, params, user) }
|
|
25
|
+
|
|
26
|
+
before(:each) do
|
|
27
|
+
# A has_one declared on the target model that actually returns MANY rows: every tree
|
|
28
|
+
# sharing the island. Eager-loading it turns one tree into N joined rows.
|
|
29
|
+
Tree.class_eval do
|
|
30
|
+
has_one :island_neighbour, class_name: 'Tree', foreign_key: 'island_id', primary_key: 'island_id'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@alice = User.create!(name: 'Alice')
|
|
34
|
+
@bob = User.create!(name: 'Bob')
|
|
35
|
+
@trees = [
|
|
36
|
+
Tree.create!(name: 'cedar', island: island, owner: @alice),
|
|
37
|
+
Tree.create!(name: 'olive', island: island, owner: @bob),
|
|
38
|
+
Tree.create!(name: 'pine', island: island, owner: @alice),
|
|
39
|
+
Tree.create!(name: 'fig', island: island, owner: @bob)
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
ForestLiana::ScopeManager.invalidate_scope_cache(rendering_id)
|
|
43
|
+
allow(ForestLiana::ScopeManager).to receive(:fetch_scopes).and_return(scopes)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
after(:each) do
|
|
47
|
+
Tree._reflections.delete('island_neighbour')
|
|
48
|
+
Tree.reflections.delete('island_neighbour')
|
|
49
|
+
%w[island_neighbour island_neighbour= build_island_neighbour
|
|
50
|
+
create_island_neighbour create_island_neighbour! reload_island_neighbour].each do |m|
|
|
51
|
+
Tree.undef_method(m) rescue nil
|
|
52
|
+
end
|
|
53
|
+
Tree.destroy_all
|
|
54
|
+
Island.destroy_all
|
|
55
|
+
User.destroy_all
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'still lists the mis-declared has_one among the eager-load candidates' do
|
|
59
|
+
expect(subject.includes).to include(:island_neighbour)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'returns exactly page_size DISTINCT records, not collapsed by the JOIN' do
|
|
63
|
+
subject.perform
|
|
64
|
+
ids = subject.records.map(&:id)
|
|
65
|
+
|
|
66
|
+
expect(ids.size).to eq(page_size)
|
|
67
|
+
expect(ids.uniq.size).to eq(page_size)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'does not JOIN the one-to-many association for a base-column sort' do
|
|
71
|
+
subject.perform
|
|
72
|
+
|
|
73
|
+
expect(subject.records.to_sql.scan(/LEFT OUTER JOIN/i)).to be_empty
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'walks every page yielding all records exactly once' do
|
|
77
|
+
seen = []
|
|
78
|
+
pages = (@trees.size.to_f / page_size).ceil
|
|
79
|
+
pages.times do |i|
|
|
80
|
+
getter = described_class.new(
|
|
81
|
+
Island, association, params.merge(page: { size: page_size, number: i + 1 }), user
|
|
82
|
+
)
|
|
83
|
+
getter.perform
|
|
84
|
+
seen.concat(getter.records.map(&:id))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
expect(seen.sort).to eq(@trees.map(&:id).sort)
|
|
88
|
+
expect(seen.uniq.size).to eq(@trees.size)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'keeps the total count correct' do
|
|
92
|
+
subject.perform
|
|
93
|
+
|
|
94
|
+
expect(subject.count).to eq(@trees.size)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
describe 'when sorting by a relation column' do
|
|
98
|
+
let(:params) do
|
|
99
|
+
{
|
|
100
|
+
id: island.id,
|
|
101
|
+
association_name: 'trees',
|
|
102
|
+
sort: 'owner.name',
|
|
103
|
+
page: { size: 15, number: 1 },
|
|
104
|
+
timezone: 'America/Nome'
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'keeps the relation joined and returns all records ordered by it' do
|
|
109
|
+
subject.perform
|
|
110
|
+
names = subject.records.to_a.map { |tree| tree.owner.name }
|
|
111
|
+
|
|
112
|
+
expect(names.size).to eq(@trees.size)
|
|
113
|
+
expect(names).to eq(names.sort)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
describe 'when filtering by a relation column' do
|
|
118
|
+
let(:params) do
|
|
119
|
+
{
|
|
120
|
+
id: island.id,
|
|
121
|
+
association_name: 'trees',
|
|
122
|
+
filters: { field: 'owner:name', operator: 'equal', value: 'Alice' }.to_json,
|
|
123
|
+
page: { size: 15, number: 1 },
|
|
124
|
+
timezone: 'America/Nome'
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'keeps the relation joined and returns only the matching records' do
|
|
129
|
+
subject.perform
|
|
130
|
+
records = subject.records.to_a
|
|
131
|
+
|
|
132
|
+
expect(records.size).to eq(2)
|
|
133
|
+
expect(records.map { |tree| tree.owner.name }.uniq).to eq(['Alice'])
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
describe 'with extended search across a display-only association' do
|
|
138
|
+
# Regression for review comment on #790: SearchQueryBuilder#search_param, with
|
|
139
|
+
# searchExtended=1, emits `OR LOWER("users"."name") LIKE ...` for every included
|
|
140
|
+
# one-association and relies on that table already being joined. `owner` isn't
|
|
141
|
+
# referenced by sort or filters here, so it would have moved to `preload` (which
|
|
142
|
+
# never joins) and blown up the query with a missing FROM-clause entry.
|
|
143
|
+
let(:params) do
|
|
144
|
+
{
|
|
145
|
+
id: island.id,
|
|
146
|
+
association_name: 'trees',
|
|
147
|
+
search: 'alice',
|
|
148
|
+
'searchExtended' => '1',
|
|
149
|
+
page: { size: 15, number: 1 },
|
|
150
|
+
timezone: 'America/Nome'
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it 'keeps the searched association joined and returns only the matching records' do
|
|
155
|
+
subject.perform
|
|
156
|
+
records = subject.records.to_a
|
|
157
|
+
|
|
158
|
+
expect(records.map(&:name)).to contain_exactly('cedar', 'pine')
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
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.20.
|
|
4
|
+
version: 9.20.11
|
|
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-08-
|
|
11
|
+
date: 2026-08-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -460,6 +460,7 @@ files:
|
|
|
460
460
|
- spec/services/forest_liana/composite_primary_keys_spec.rb
|
|
461
461
|
- spec/services/forest_liana/filters_parser_spec.rb
|
|
462
462
|
- spec/services/forest_liana/forest_api_requester_spec.rb
|
|
463
|
+
- spec/services/forest_liana/has_many_getter_pagination_spec.rb
|
|
463
464
|
- spec/services/forest_liana/has_many_getter_spec.rb
|
|
464
465
|
- spec/services/forest_liana/ip_whitelist_checker_spec.rb
|
|
465
466
|
- spec/services/forest_liana/line_stat_getter_spec.rb
|
|
@@ -775,6 +776,7 @@ test_files:
|
|
|
775
776
|
- spec/services/forest_liana/composite_primary_keys_spec.rb
|
|
776
777
|
- spec/services/forest_liana/filters_parser_spec.rb
|
|
777
778
|
- spec/services/forest_liana/forest_api_requester_spec.rb
|
|
779
|
+
- spec/services/forest_liana/has_many_getter_pagination_spec.rb
|
|
778
780
|
- spec/services/forest_liana/has_many_getter_spec.rb
|
|
779
781
|
- spec/services/forest_liana/ip_whitelist_checker_spec.rb
|
|
780
782
|
- spec/services/forest_liana/line_stat_getter_spec.rb
|