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: 5449af5d3c0aa87c4c56f8417d3527e279690d22a34f02cb081cee74d2de06b1
4
- data.tar.gz: '044797af4de1b516a177d859566fdbdea4e5458eb938c107077d01a19314a763'
3
+ metadata.gz: a13f2892e518d41f6c0aae28e4f14397086cd58f19b25c4b1fa86b4fefeaf911
4
+ data.tar.gz: 76853d12f9eddbf87235f2a407814d3e23938a35f51c1e77a33f99ccdee0a5e7
5
5
  SHA512:
6
- metadata.gz: cce3c3edaacf3cea3dfc9ea29f3a7391cf2e1a09b399784c1be06c767d6013e600d70c7ca2fa70673451c0e18431df0d521e3cff2285408c003ef4911ca18336
7
- data.tar.gz: b56dc37e18b8f8e78e730c1d8eb844731134d22be8478a27e4ea576ff75df9523f6c6ba7b7da8b07a585b97419d9b2d3058656527acad2dcb94146533e9afabd
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
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.20.10"
2
+ VERSION = "9.20.11"
3
3
  end
@@ -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.10
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 00:00:00.000000000 Z
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