forest_liana 9.22.2 → 9.22.3

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: 277b2499887f0ff82ea53ca55fc79843541cef4709f064c70f5efa55a4735d53
4
+ data.tar.gz: f0fbeb9771dae6c1db93581279e433815f47eca0e257543f24bf012057da51d3
5
5
  SHA512:
6
- metadata.gz: 6aaa74f4333c30a763077f5ef1e03247e9c42fd190608ef453263406485ecee48aae37fe8b8b927be1e5fadcf5eb12105c9ea9f5e1b43d7e6e66f78d6a1799af
7
- data.tar.gz: 18e7851795ff42410beb2a87db9fb0cec346e4734cd04401461974529bf3d8e4a4ee61fca0e1d143428447f199b6c42cafe48f7c40a4e2d0c2841773e4bf254c
6
+ metadata.gz: 8e02b44b8348a306b0e7e60b44da087c0effb4ba538947e9165e6db2e156698b4a276a0288335ea8b7768454e0d4309ef4232e37c994abd2adffc04763f66f2b
7
+ data.tar.gz: 2be7bc1df7f51619407f1fe8f2b49a8e7b5629ada6151677e6f1f9a57a5548ffbeec5cfdb33f31cb8ca015a26937fb73e96f7decb9b63e959903e69497791f1b
@@ -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'
@@ -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.3"
3
3
  end
@@ -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.3
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-25 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