forest_liana 9.22.2 → 9.22.4

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: 2519878c68b98965bc3b8e31cf9ae9e8253f9d215cf724d99233e55cb37ab8c5
4
- data.tar.gz: ae92f459f29ab022290ad09f33e9d1cec654d86fce2784cb425e8c8924b87e59
3
+ metadata.gz: f8365b7c92d4157a34acd7f00af2c3d515976b96884eef48c0a0e72c775b1ea0
4
+ data.tar.gz: a61cfdd476921b56e293014854693c3aa39ffd6d8474544273be222f91c5d0db
5
5
  SHA512:
6
- metadata.gz: 6aaa74f4333c30a763077f5ef1e03247e9c42fd190608ef453263406485ecee48aae37fe8b8b927be1e5fadcf5eb12105c9ea9f5e1b43d7e6e66f78d6a1799af
7
- data.tar.gz: 18e7851795ff42410beb2a87db9fb0cec346e4734cd04401461974529bf3d8e4a4ee61fca0e1d143428447f199b6c42cafe48f7c40a4e2d0c2841773e4bf254c
6
+ metadata.gz: 8ee81334205b47404f4fc9349fcde08b8d38be877a525a1cfb8bc1481de1b435d88b0724d48552cbc111735a751b2f84c60a49cc4142a5a7c3a9fab6df201d49
7
+ data.tar.gz: dec06373edbb86308bb9577c0f20d61a747ad9fa0fac691b1f70fadada3d2e27406337b188eda61f10c5ad96405c6d65db77bd4e5144dd079e9445d1b1a13cc4
@@ -1,22 +1,22 @@
1
1
  module ForestLiana
2
2
  module AggregationHelper
3
- def resolve_field_path(field_param, default_field = 'id')
3
+ def resolve_field_path(field_param, default_field = 'id', resource = @resource)
4
4
  if field_param.blank?
5
- default_field ||= @resource.primary_key || 'id'
6
- return "#{@resource.table_name}.#{default_field}"
5
+ default_field ||= resource.primary_key || 'id'
6
+ return "#{resource.table_name}.#{default_field}"
7
7
  end
8
8
 
9
9
  if field_param.include?(':')
10
10
  association, field = field_param.split ':'
11
- associated_resource = @resource.reflect_on_association(association.to_sym)
11
+ associated_resource = resource.reflect_on_association(association.to_sym)
12
12
  "#{associated_resource.table_name}.#{field}"
13
13
  else
14
- "#{@resource.table_name}.#{field_param}"
14
+ "#{resource.table_name}.#{field_param}"
15
15
  end
16
16
  end
17
17
 
18
- def aggregation_sql(type, field)
19
- field_path = resolve_field_path(field)
18
+ def aggregation_sql(type, field, resource = @resource)
19
+ field_path = resolve_field_path(field, 'id', resource)
20
20
 
21
21
  case type
22
22
  when 'sum'
@@ -110,15 +110,16 @@ module ForestLiana
110
110
  private
111
111
 
112
112
  def sort_array_of_objects(array)
113
- array.sort! do |element1, element2|
114
- [element1['type'], element1['id']] <=> [element2['type'], element2['id']]
115
- end
113
+ array.sort_by! { |element| [element['type'].to_s, element['id'].to_s] }
116
114
  end
117
115
 
116
+ # NOTICE: A field 'type' is a String ('String'), an Array (['String'] for an array column)
117
+ # or a Hash (a nested type), and those are not comparable with one another:
118
+ # `['String'] <=> 'String'` returns nil, which makes `sort` raise an ArgumentError as soon
119
+ # as two fields share a name. Only the tie-breaker ever needs to be total, so JSON-ifying
120
+ # that one alone keeps the primary key byte-for-byte identical and the apimap churn-free.
118
121
  def sort_array_of_fields(array)
119
- array.sort do |field1, field2|
120
- [field1['field'], field1['type']] <=> [field2['field'], field2['type']]
121
- end
122
+ array.sort_by { |field| [field['field'].to_s, field['type'].to_json] }
122
123
  end
123
124
 
124
125
  def reorder_keys_basic(object)
@@ -4,9 +4,10 @@ module ForestLiana
4
4
 
5
5
  def initialize(parent_model, params, forest_user)
6
6
  @resource = parent_model
7
- @scoped_parent_model = get_scoped_model(parent_model, forest_user, params[:timezone])
8
- child_model = @scoped_parent_model.reflect_on_association(params[:relationshipFieldName]).klass
9
- @scoped_child_model = get_scoped_model(child_model, forest_user, params[:timezone])
7
+ @scoped_parent_model = get_scoped_model(parent_model, forest_user, params[:timezone]) || parent_model.unscoped
8
+ @relationship_field_name = params[:relationshipFieldName].to_sym
9
+ @child_model = @scoped_parent_model.reflect_on_association(@relationship_field_name).klass
10
+ @child_scope = get_scoped_model(@child_model, forest_user, params[:timezone])
10
11
  @label_field = params[:labelFieldName]
11
12
  @aggregate = params[:aggregator].downcase
12
13
  @aggregate_field = params[:aggregateFieldName]
@@ -15,17 +16,25 @@ module ForestLiana
15
16
  end
16
17
 
17
18
  def perform
18
- includes = ForestLiana::QueryHelper.get_one_association_names_symbol(@scoped_child_model)
19
-
20
19
  alias_name = aggregation_alias(@aggregate, @aggregate_field)
21
-
22
- result = @scoped_child_model
23
- .joins(includes)
24
- .where({ @scoped_parent_model.name.downcase.to_sym => @scoped_parent_model })
20
+ # NOTICE: The chart groups on the parent collection and aggregates its related records, so
21
+ # the query starts from the parent: querying the related model leaves the parent
22
+ # table out of the FROM clause unless it happens to be one of its belongs_to.
23
+ aggregation = aggregation_sql(@aggregate, @aggregate_field, @child_model)
24
+ records = @scoped_parent_model.joins(@relationship_field_name)
25
+
26
+ # NOTICE: The related collection scope goes through a subquery: merging it would re-root its
27
+ # joins onto the parent, and reorder drops the ordering of the parent default_scope.
28
+ if @child_scope
29
+ child_key = @child_model.primary_key
30
+ records = records.where(@child_model.table_name => { child_key => @child_scope.select(child_key) })
31
+ end
32
+
33
+ result = records
25
34
  .group(@group_by)
26
- .order(Arel.sql("#{alias_name} DESC"))
35
+ .reorder(Arel.sql("#{alias_name} DESC"))
27
36
  .limit(@limit)
28
- .pluck(@group_by, Arel.sql("#{aggregation_sql(@aggregate, @aggregate_field)} AS #{alias_name}"))
37
+ .pluck(@group_by, Arel.sql("#{aggregation} AS #{alias_name}"))
29
38
  .map { |key, value| { key: key, value: value } }
30
39
 
31
40
  @record = Model::Stat.new(value: result)
@@ -34,7 +43,7 @@ module ForestLiana
34
43
  def get_scoped_model(model, forest_user, timezone)
35
44
  scope_filters = ForestLiana::ScopeManager.get_scope(model.name, forest_user)
36
45
 
37
- return model.unscoped if scope_filters.blank?
46
+ return if scope_filters.blank?
38
47
 
39
48
  FiltersParser.new(scope_filters, model, timezone, @params).apply_filters
40
49
  end
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.22.2"
2
+ VERSION = "9.22.4"
3
3
  end
@@ -171,6 +171,80 @@ module ForestLiana
171
171
  expect(apimap_sorted['meta']['stack'].keys).to eq(['database_type', 'orm_version'])
172
172
  end
173
173
  end
174
+
175
+ context 'on an apimap with fields sharing a name but not a comparable type' do
176
+ # NOTICE: a field 'type' is a String ('String'), an Array (['String'] for an array
177
+ # column) or a Hash (a nested type). The sort used to compare those values
178
+ # directly, so `['String'] <=> 'String'` returned nil and `sort` raised
179
+ # `ArgumentError: comparison of Hash with Hash failed`. The rescue in `perform`
180
+ # swallowed it and returned a half-processed apimap: collections that were never
181
+ # reached kept attributes outside KEYS_COLLECTION (such as 'search_fields'), and
182
+ # the Forest API rejects those with `HTTP 400 ValidationFailedError`.
183
+ let(:apimap) do
184
+ ActiveSupport::JSON.decode(ActiveSupport::JSON.encode({
185
+ 'meta': {
186
+ stack: {
187
+ 'orm_version': '4.34.9',
188
+ 'database_type': 'postgresql',
189
+ },
190
+ liana: 'forest-rails',
191
+ 'liana_version': '1.5.24',
192
+ },
193
+ 'data': [{
194
+ id: 'posts',
195
+ type: 'collections',
196
+ attributes: {
197
+ name: 'posts',
198
+ fields: [
199
+ { field: 'tags', type: ['String'] },
200
+ { field: 'tags', type: 'String' },
201
+ { field: 'meta', type: { fields: [{ field: 'locale', type: 'String' }] } },
202
+ { field: 'meta', type: 'String' },
203
+ { field: 'id', type: 'Number' },
204
+ ],
205
+ }
206
+ }, {
207
+ id: 'zebras',
208
+ type: 'collections',
209
+ attributes: {
210
+ 'search_fields': ['name'],
211
+ fields: [
212
+ { field: 'id', type: 'Number' },
213
+ ],
214
+ name: 'zebras',
215
+ }
216
+ }]
217
+ }))
218
+ end
219
+
220
+ let(:apimap_sorted) { ApimapSorter.new(apimap).perform }
221
+
222
+ it 'should reorder the whole apimap without bailing out' do
223
+ expect(FOREST_LOGGER).not_to receive(:warn)
224
+
225
+ apimap_sorted
226
+ end
227
+
228
+ it 'should sort the fields of the offending collection' do
229
+ expect(apimap_sorted['data'][0]['attributes']['fields'].map { |field| field['field'] })
230
+ .to eq(['id', 'meta', 'meta', 'tags', 'tags'])
231
+ end
232
+
233
+ it 'should keep a deterministic order between the non-comparable types' do
234
+ tag_types = apimap_sorted['data'][0]['attributes']['fields']
235
+ .select { |field| field['field'] == 'tags' }
236
+ .map { |field| field['type'] }
237
+ expect(tag_types).to eq(['String', ['String']])
238
+ end
239
+
240
+ it 'should keep processing the collections after the offending one' do
241
+ expect(apimap_sorted['data'][1]['attributes'].keys).to eq(['name', 'fields'])
242
+ end
243
+
244
+ it 'should not leak collection attributes that are not part of the schema' do
245
+ expect(apimap_sorted['data'][1]['attributes']).not_to have_key('search_fields')
246
+ end
247
+ end
174
248
  end
175
249
 
176
250
  describe 'a smart field dependencies key' do
@@ -0,0 +1,157 @@
1
+ module ForestLiana
2
+ describe LeaderboardStatGetter do
3
+ let(:rendering_id) { 13 }
4
+ let(:user) { { 'id' => '1', 'rendering_id' => rendering_id } }
5
+ let(:scopes) { {'scopes' => {}, 'team' => {'id' => '1', 'name' => 'Operations'}} }
6
+
7
+ before(:each) do
8
+ ForestLiana::ScopeManager.invalidate_scope_cache(rendering_id)
9
+ allow(ForestLiana::ScopeManager).to receive(:fetch_scopes).and_return(scopes)
10
+ end
11
+
12
+ describe 'on a relationship whose model does not belong to the parent collection' do
13
+ let(:params) {
14
+ {
15
+ type: 'Leaderboard',
16
+ timezone: 'Europe/Paris',
17
+ relationshipFieldName: 'addresses',
18
+ labelFieldName: 'name',
19
+ aggregator: 'Count',
20
+ limit: 5
21
+ }
22
+ }
23
+
24
+ before(:each) do
25
+ king = User.create!(name: 'Aegon', title: 'king')
26
+ villager = User.create!(name: 'Davos', title: 'villager')
27
+
28
+ 2.times { |index| Address.create!(line1: "#{index} Dragonstone", addressable: king) }
29
+ Address.create!(line1: '1 Flea Bottom', addressable: villager)
30
+ end
31
+
32
+ it 'ranks the parent records by number of related records' do
33
+ getter = LeaderboardStatGetter.new(User, params, user)
34
+ getter.perform
35
+
36
+ expect(getter.record.value).to eq([
37
+ { key: 'Aegon', value: 2 },
38
+ { key: 'Davos', value: 1 }
39
+ ])
40
+ end
41
+ end
42
+
43
+ describe 'on a relationship whose model belongs to the parent collection' do
44
+ let(:aggregator) { 'Count' }
45
+ let(:aggregate_field) { nil }
46
+ let(:params) {
47
+ {
48
+ type: 'Leaderboard',
49
+ timezone: 'Europe/Paris',
50
+ relationshipFieldName: 'trees',
51
+ labelFieldName: 'name',
52
+ aggregator: aggregator,
53
+ aggregateFieldName: aggregate_field,
54
+ limit: 5
55
+ }
56
+ }
57
+
58
+ before(:each) do
59
+ dragonstone = Island.create!(name: 'Dragonstone')
60
+ skagos = Island.create!(name: 'Skagos')
61
+
62
+ Tree.create!(name: 'Old Tree', age: 15, island: dragonstone)
63
+ Tree.create!(name: 'Young Tree', age: 3, island: dragonstone)
64
+ Tree.create!(name: 'Lone Tree', age: 30, island: skagos)
65
+ end
66
+
67
+ it 'counts the related records, not the parent ones' do
68
+ getter = LeaderboardStatGetter.new(Island, params, user)
69
+ getter.perform
70
+
71
+ expect(getter.record.value).to eq([
72
+ { key: 'Dragonstone', value: 2 },
73
+ { key: 'Skagos', value: 1 }
74
+ ])
75
+ end
76
+
77
+ context 'when the chart sums a field of the related records' do
78
+ let(:aggregator) { 'Sum' }
79
+ let(:aggregate_field) { 'age' }
80
+
81
+ it 'sums that field on the related model' do
82
+ getter = LeaderboardStatGetter.new(Island, params, user)
83
+ getter.perform
84
+
85
+ expect(getter.record.value).to eq([
86
+ { key: 'Skagos', value: 30 },
87
+ { key: 'Dragonstone', value: 18 }
88
+ ])
89
+ end
90
+ end
91
+
92
+ context 'when the related collection is scoped on a field of its own relationship' do
93
+ let(:scopes) {
94
+ {
95
+ 'scopes' => {
96
+ 'Tree' => { 'aggregator' => 'and', 'conditions' => [{ 'field' => 'owner:name', 'operator' => 'equal', 'value' => 'Aegon' }] }
97
+ },
98
+ 'team' => { 'id' => '1', 'name' => 'Operations' }
99
+ }
100
+ }
101
+
102
+ before(:each) do
103
+ Tree.find_by!(name: 'Lone Tree').update!(owner: User.create!(name: 'Aegon'))
104
+ Tree.find_by!(name: 'Old Tree').update!(owner: User.create!(name: 'Davos'))
105
+ end
106
+
107
+ it 'only aggregates the related records the scope keeps' do
108
+ getter = LeaderboardStatGetter.new(Island, params, user)
109
+ getter.perform
110
+
111
+ expect(getter.record.value).to eq([
112
+ { key: 'Skagos', value: 1 }
113
+ ])
114
+ end
115
+ end
116
+ end
117
+
118
+ describe 'on a scoped parent collection whose default_scope orders' do
119
+ let(:scopes) {
120
+ {
121
+ 'scopes' => {
122
+ 'Owner' => { 'aggregator' => 'and', 'conditions' => [{ 'field' => 'name', 'operator' => 'not_equal', 'value' => 'Theon' }] }
123
+ },
124
+ 'team' => { 'id' => '1', 'name' => 'Operations' }
125
+ }
126
+ }
127
+ let(:params) {
128
+ {
129
+ type: 'Leaderboard',
130
+ timezone: 'Europe/Paris',
131
+ relationshipFieldName: 'trees',
132
+ labelFieldName: 'name',
133
+ aggregator: 'Count',
134
+ limit: 5
135
+ }
136
+ }
137
+
138
+ before(:each) do
139
+ jon = Owner.create!(name: 'Jon', hired_at: 2.years.ago)
140
+ sansa = Owner.create!(name: 'Sansa', hired_at: 1.year.ago)
141
+
142
+ Tree.create!(name: 'Weirwood', owner_id: jon.id)
143
+ 2.times { |index| Tree.create!(name: "Pine #{index}", owner_id: sansa.id) }
144
+ end
145
+
146
+ it 'ranks by the aggregate, not by the default_scope ordering' do
147
+ getter = LeaderboardStatGetter.new(Owner, params, user)
148
+ getter.perform
149
+
150
+ expect(getter.record.value).to eq([
151
+ { key: 'Sansa', value: 2 },
152
+ { key: 'Jon', value: 1 }
153
+ ])
154
+ end
155
+ end
156
+ end
157
+ 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.22.2
4
+ version: 9.22.4
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-24 00:00:00.000000000 Z
11
+ date: 2026-09-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -501,6 +501,7 @@ files:
501
501
  - spec/services/forest_liana/has_many_getter_pagination_spec.rb
502
502
  - spec/services/forest_liana/has_many_getter_spec.rb
503
503
  - spec/services/forest_liana/ip_whitelist_checker_spec.rb
504
+ - spec/services/forest_liana/leaderboard_stat_getter_spec.rb
504
505
  - spec/services/forest_liana/line_stat_getter_spec.rb
505
506
  - spec/services/forest_liana/pie_stat_getter_spec.rb
506
507
  - spec/services/forest_liana/projection_parser_spec.rb
@@ -848,6 +849,7 @@ test_files:
848
849
  - spec/services/forest_liana/has_many_getter_pagination_spec.rb
849
850
  - spec/services/forest_liana/has_many_getter_spec.rb
850
851
  - spec/services/forest_liana/ip_whitelist_checker_spec.rb
852
+ - spec/services/forest_liana/leaderboard_stat_getter_spec.rb
851
853
  - spec/services/forest_liana/line_stat_getter_spec.rb
852
854
  - spec/services/forest_liana/pie_stat_getter_spec.rb
853
855
  - spec/services/forest_liana/projection_parser_spec.rb