forest_liana 9.14.3 → 9.14.5

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: 82aa2dffa179fa0be6398321388077e3ad04f66583969c4fbb68112983dcd5d6
4
- data.tar.gz: 9e84f6554455e681cf00ca15cee8bb337c4e9ae22289e11959caa889297a0ee1
3
+ metadata.gz: 9ba28199210707c78a20dc527719d5a3b71e68079f6ca96f18602f978cb87f09
4
+ data.tar.gz: 60d8e40c7d69086c8c627f61e99bcd6612a5425285d29f3520596e01c0752103
5
5
  SHA512:
6
- metadata.gz: 0cd4367dcab7b965bccf0b49e289509ce0b97b9763491b67273444c06db7a226d4c1fc019da63167567de45afcb752fb76751b4512d0c5ff0754f217679bac0d
7
- data.tar.gz: 67eb9531fc902db2a9db22cffb6b53ba2f5ff9635f67ef5668db5b107102fb2ddac46b901fa56a7ac245c0fff3215c064b66cede4c37ea819985ce78539346dd
6
+ metadata.gz: 93ab4cf8bd8f375d38dae4ab1a337bd8a87d6fea98aff86f930468f4a293d60c2776a538916ce6816976f4dc8bf4e5823c809a44e36d684394859546be87adfc
7
+ data.tar.gz: 91471a98c2418ea67acdcac5e5d6539a611c7d6e717c35ee76a98189aeb71d94534c4535ca4e7c387eaa7d2997216594d4087e936c6bacb4561bd54cd7a0dbb1
@@ -146,7 +146,10 @@ module ForestLiana
146
146
  @options[:fields][relation_class_name]&.size == 1 &&
147
147
  @options[:fields][relation_class_name]&.include?(relation.klass.primary_key.to_sym)
148
148
 
149
- attr_data[:attr_or_block] = proc { relation.klass.new(relation.klass.primary_key => object.send(relation.foreign_key.to_sym)) }
149
+ attr_data[:attr_or_block] = proc {
150
+ foreign_key_value = object.send(relation.foreign_key.to_sym)
151
+ foreign_key_value.nil? ? nil : relation.klass.new(relation.klass.primary_key => foreign_key_value)
152
+ }
150
153
  end
151
154
  end
152
155
 
@@ -0,0 +1,42 @@
1
+ module ForestLiana
2
+ module AggregationHelper
3
+ def resolve_field_path(field_param, default_field = 'id')
4
+ if field_param.blank?
5
+ default_field ||= @resource.primary_key || 'id'
6
+ return "#{@resource.table_name}.#{default_field}"
7
+ end
8
+
9
+ if field_param.include?(':')
10
+ association, field = field_param.split ':'
11
+ associated_resource = @resource.reflect_on_association(association.to_sym)
12
+ "#{associated_resource.table_name}.#{field}"
13
+ else
14
+ "#{@resource.table_name}.#{field_param}"
15
+ end
16
+ end
17
+
18
+ def aggregation_sql(type, field)
19
+ field_path = resolve_field_path(field)
20
+
21
+ case type
22
+ when 'sum'
23
+ "SUM(#{field_path})"
24
+ when 'count'
25
+ "COUNT(DISTINCT #{field_path})"
26
+ else
27
+ raise "Unsupported aggregator : #{type}"
28
+ end
29
+ end
30
+
31
+ def aggregation_alias(type, field)
32
+ case type
33
+ when 'sum'
34
+ "sum_#{field.downcase}"
35
+ when 'count'
36
+ 'count_id'
37
+ else
38
+ raise "Unsupported aggregator : #{type}"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,6 +1,9 @@
1
1
  module ForestLiana
2
2
  class LeaderboardStatGetter < StatGetter
3
+ include AggregationHelper
4
+
3
5
  def initialize(parent_model, params, forest_user)
6
+ @resource = parent_model
4
7
  @scoped_parent_model = get_scoped_model(parent_model, forest_user, params[:timezone])
5
8
  child_model = @scoped_parent_model.reflect_on_association(params[:relationshipFieldName]).klass
6
9
  @scoped_child_model = get_scoped_model(child_model, forest_user, params[:timezone])
@@ -14,13 +17,15 @@ module ForestLiana
14
17
  def perform
15
18
  includes = ForestLiana::QueryHelper.get_one_association_names_symbol(@scoped_child_model)
16
19
 
20
+ alias_name = aggregation_alias(@aggregate, @aggregate_field)
21
+
17
22
  result = @scoped_child_model
18
23
  .joins(includes)
19
24
  .where({ @scoped_parent_model.name.downcase.to_sym => @scoped_parent_model })
20
25
  .group(@group_by)
21
- .order(order)
26
+ .order(Arel.sql("#{alias_name} DESC"))
22
27
  .limit(@limit)
23
- .send(@aggregate, @aggregate_field)
28
+ .pluck(@group_by, Arel.sql("#{aggregation_sql(@aggregate, @aggregate_field)} AS #{alias_name}"))
24
29
  .map { |key, value| { key: key, value: value } }
25
30
 
26
31
  @record = Model::Stat.new(value: result)
@@ -33,18 +38,5 @@ module ForestLiana
33
38
 
34
39
  FiltersParser.new(scope_filters, model, timezone, @params).apply_filters
35
40
  end
36
-
37
- def order
38
- order = 'DESC'
39
-
40
- # NOTICE: The generated alias for a count is "count_all", for a sum the
41
- # alias looks like "sum_#{aggregate_field}"
42
- if @aggregate == 'sum'
43
- field = @aggregate_field.downcase
44
- else
45
- field = 'all'
46
- end
47
- "#{@aggregate}_#{field} #{order}"
48
- end
49
41
  end
50
42
  end
@@ -1,5 +1,6 @@
1
1
  module ForestLiana
2
2
  class PieStatGetter < StatGetter
3
+ include AggregationHelper
3
4
  attr_accessor :record
4
5
 
5
6
  def perform
@@ -13,11 +14,16 @@ module ForestLiana
13
14
  resource = FiltersParser.new(filters, resource, @params[:timezone], @params).apply_filters
14
15
  end
15
16
 
16
- result = resource
17
- .group(groupByFieldName)
18
- .order(order)
19
- .send(@params[:aggregator].downcase, @params[:aggregateFieldName])
20
- .map do |key, value|
17
+ aggregation_type = @params[:aggregator].downcase
18
+ aggregation_field = @params[:aggregateFieldName]
19
+ alias_name = aggregation_alias(aggregation_type, aggregation_field)
20
+
21
+ resource = resource
22
+ .group(groupByFieldName)
23
+ .order(Arel.sql("#{alias_name} DESC"))
24
+ .pluck(groupByFieldName, Arel.sql("#{aggregation_sql(aggregation_type, aggregation_field)} AS #{alias_name}"))
25
+
26
+ result = resource.map do |key, value|
21
27
  # NOTICE: Display the enum name instead of an integer if it is an
22
28
  # "Enum" field type on old Rails version (before Rails
23
29
  # 5.1.3).
@@ -38,28 +44,7 @@ module ForestLiana
38
44
  end
39
45
 
40
46
  def groupByFieldName
41
- if @params[:groupByFieldName].include? ':'
42
- association, field = @params[:groupByFieldName].split ':'
43
- resource = @resource.reflect_on_association(association.to_sym)
44
- "#{resource.table_name}.#{field}"
45
- else
46
- "#{@resource.table_name}.#{@params[:groupByFieldName]}"
47
- end
48
- end
49
-
50
- def order
51
- order = 'DESC'
52
-
53
- # NOTICE: The generated alias for a count is "count_all", for a sum the
54
- # alias looks like "sum_#{aggregateFieldName}"
55
- if @params[:aggregator].downcase == 'sum'
56
- field = @params[:aggregateFieldName].downcase
57
- else
58
- # `count_id` is required only for rails v5
59
- field = Rails::VERSION::MAJOR == 5 || @includes.size > 0 ? 'id' : 'all'
60
- end
61
- "#{@params[:aggregator].downcase}_#{field} #{order}"
47
+ resolve_field_path(@params[:groupByFieldName])
62
48
  end
63
-
64
49
  end
65
50
  end
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.14.3"
2
+ VERSION = "9.14.5"
3
3
  end
@@ -1,7 +1,7 @@
1
1
  class Tree < ActiveRecord::Base
2
2
  belongs_to :owner, class_name: 'User', inverse_of: :trees_owned
3
3
  belongs_to :cutter, class_name: 'User', inverse_of: :trees_cut
4
- belongs_to :island
4
+ belongs_to :island, optional: true
5
5
  belongs_to :eponymous_island,
6
6
  ->(record) { where(name: record.name) },
7
7
  class_name: 'Island',
@@ -133,5 +133,187 @@ module ForestLiana
133
133
  end
134
134
  end
135
135
  end
136
+
137
+ describe 'aggregation methods behavior' do
138
+ let(:scopes) { {'scopes' => {}, 'team' => {'id' => '1', 'name' => 'Operations'}} }
139
+ let(:model) { Tree }
140
+ let(:collection) { 'trees' }
141
+ let(:groupByFieldName) { 'age' }
142
+
143
+ describe 'aggregation_sql method' do
144
+ subject { PieStatGetter.new(model, params, user) }
145
+
146
+ context 'with COUNT aggregator' do
147
+ let(:params) {
148
+ {
149
+ type: 'Pie',
150
+ sourceCollectionName: collection,
151
+ timezone: 'Europe/Paris',
152
+ aggregator: 'Count',
153
+ groupByFieldName: groupByFieldName
154
+ }
155
+ }
156
+
157
+ it 'should generate correct COUNT SQL' do
158
+ sql = subject.send(:aggregation_sql, 'count', nil)
159
+ expect(sql).to eq 'COUNT(DISTINCT trees.id)'
160
+ end
161
+
162
+ it 'should generate correct COUNT SQL with specific field' do
163
+ sql = subject.send(:aggregation_sql, 'count', 'age')
164
+ expect(sql).to eq 'COUNT(DISTINCT trees.age)'
165
+ end
166
+ end
167
+
168
+ context 'with SUM aggregator' do
169
+ let(:params) {
170
+ {
171
+ type: 'Pie',
172
+ sourceCollectionName: collection,
173
+ timezone: 'Europe/Paris',
174
+ aggregator: 'Sum',
175
+ aggregateFieldName: 'age',
176
+ groupByFieldName: groupByFieldName
177
+ }
178
+ }
179
+
180
+ it 'should generate correct SUM SQL' do
181
+ sql = subject.send(:aggregation_sql, 'sum', 'age')
182
+ expect(sql).to eq 'SUM(trees.age)'
183
+ end
184
+ end
185
+
186
+ context 'with association field' do
187
+ let(:params) {
188
+ {
189
+ type: 'Pie',
190
+ sourceCollectionName: collection,
191
+ timezone: 'Europe/Paris',
192
+ aggregator: 'Count',
193
+ groupByFieldName: 'owner:name'
194
+ }
195
+ }
196
+
197
+ it 'should handle association fields correctly' do
198
+ # Assuming Tree belongs_to :owner
199
+ allow(model).to receive(:reflect_on_association).with(:owner).and_return(
200
+ double(table_name: 'owners')
201
+ )
202
+
203
+ sql = subject.send(:aggregation_sql, 'count', 'owner:id')
204
+ expect(sql).to eq 'COUNT(DISTINCT owners.id)'
205
+ end
206
+ end
207
+
208
+ context 'with unsupported aggregator' do
209
+ let(:params) {
210
+ {
211
+ type: 'Pie',
212
+ sourceCollectionName: collection,
213
+ timezone: 'Europe/Paris',
214
+ aggregator: 'Invalid',
215
+ groupByFieldName: groupByFieldName
216
+ }
217
+ }
218
+
219
+ it 'should raise an error for unsupported aggregator' do
220
+ expect {
221
+ subject.send(:aggregation_sql, 'invalid', 'age')
222
+ }.to raise_error(ForestLiana::Errors::HTTP422Error)
223
+ end
224
+ end
225
+ end
226
+
227
+ describe 'aggregation_alias method' do
228
+ subject { PieStatGetter.new(model, params, user) }
229
+
230
+ context 'with COUNT aggregator' do
231
+ let(:params) {
232
+ {
233
+ type: 'Pie',
234
+ sourceCollectionName: collection,
235
+ timezone: 'Europe/Paris',
236
+ aggregator: 'Count',
237
+ groupByFieldName: groupByFieldName
238
+ }
239
+ }
240
+
241
+ it 'should return correct alias for count' do
242
+ alias_name = subject.send(:aggregation_alias, 'count', nil)
243
+ expect(alias_name).to eq 'count_id'
244
+ end
245
+ end
246
+
247
+ context 'with SUM aggregator' do
248
+ let(:params) {
249
+ {
250
+ type: 'Pie',
251
+ sourceCollectionName: collection,
252
+ timezone: 'Europe/Paris',
253
+ aggregator: 'Sum',
254
+ aggregateFieldName: 'age',
255
+ groupByFieldName: groupByFieldName
256
+ }
257
+ }
258
+
259
+ it 'should return correct alias for sum' do
260
+ alias_name = subject.send(:aggregation_alias, 'sum', 'age')
261
+ expect(alias_name).to eq 'sum_age'
262
+ end
263
+
264
+ it 'should handle field names with mixed case' do
265
+ alias_name = subject.send(:aggregation_alias, 'sum', 'TreeAge')
266
+ expect(alias_name).to eq 'sum_treeage'
267
+ end
268
+ end
269
+ end
270
+
271
+ describe 'results ordering' do
272
+ subject { PieStatGetter.new(model, params, user) }
273
+
274
+ context 'with COUNT aggregator' do
275
+ let(:params) {
276
+ {
277
+ type: 'Pie',
278
+ sourceCollectionName: collection,
279
+ timezone: 'Europe/Paris',
280
+ aggregator: 'Count',
281
+ groupByFieldName: groupByFieldName
282
+ }
283
+ }
284
+
285
+ it 'should return results ordered by count descending' do
286
+ subject.perform
287
+
288
+ expect(subject.record.value).to eq [
289
+ { :key => 3, :value => 5},
290
+ { :key => 15, :value => 4 }
291
+ ]
292
+ end
293
+ end
294
+
295
+ context 'with SUM aggregator' do
296
+ let(:params) {
297
+ {
298
+ type: 'Pie',
299
+ sourceCollectionName: collection,
300
+ timezone: 'Europe/Paris',
301
+ aggregator: 'Sum',
302
+ aggregateFieldName: 'age',
303
+ groupByFieldName: groupByFieldName
304
+ }
305
+ }
306
+
307
+ it 'should return results ordered by sum descending' do
308
+ subject.perform
309
+
310
+ expect(subject.record.value).to eq [
311
+ { :key => 15, :value => 60 },
312
+ { :key => 3, :value => 15 }
313
+ ]
314
+ end
315
+ end
316
+ end
317
+ end
136
318
  end
137
319
  end
@@ -0,0 +1,53 @@
1
+ module ForestLiana
2
+ describe SerializerFactory do
3
+ describe '#serializer_for has_one_relationships patch' do
4
+ let(:user) { User.create!(name: 'PatchTest') }
5
+ let(:island) { Island.create!(name: 'TestIsland') }
6
+ let(:tree) { Tree.create!(name: 'TestTree', island: island, owner: user) }
7
+
8
+ it 'simulates has_one relation with only primary key' do
9
+ factory = described_class.new
10
+ serializer_class = factory.serializer_for(Tree)
11
+
12
+ serializer_class.send(:has_one, :island) { }
13
+
14
+ instance = serializer_class.new(tree, fields: {
15
+ 'Island' => [:id],
16
+ 'Tree' => [:island]
17
+ })
18
+
19
+ relationships = instance.send(:has_one_relationships)
20
+ expect(relationships).to have_key(:island)
21
+ relation_data = relationships[:island]
22
+ expect(relation_data[:attr_or_block]).to be_a(Proc)
23
+ model = relation_data[:attr_or_block].call
24
+
25
+ expect(model).to be_a(Island)
26
+ expect(model.id).to eq(island.id)
27
+ end
28
+
29
+ it 'returns nil if foreign key is nil' do
30
+ tree_without_island = Tree.create!(name: 'NoIslandTree', island_id: nil, owner: user)
31
+
32
+ factory = described_class.new
33
+ serializer_class = factory.serializer_for(Tree)
34
+
35
+ serializer_class.send(:has_one, :island) { }
36
+
37
+ instance = serializer_class.new(tree_without_island, fields: {
38
+ 'Island' => [:id],
39
+ 'Tree' => [:island]
40
+ })
41
+
42
+ relationships = instance.send(:has_one_relationships)
43
+ expect(relationships).to have_key(:island)
44
+ relation_data = relationships[:island]
45
+ expect(relation_data[:attr_or_block]).to be_a(Proc)
46
+ model = relation_data[:attr_or_block].call
47
+
48
+ expect(model).to be_nil
49
+ end
50
+
51
+ end
52
+ end
53
+ 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.14.3
4
+ version: 9.14.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sandro Munda
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-06-19 00:00:00.000000000 Z
11
+ date: 2025-06-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -254,6 +254,7 @@ files:
254
254
  - app/services/forest_liana/ability/permission.rb
255
255
  - app/services/forest_liana/ability/permission/request_permission.rb
256
256
  - app/services/forest_liana/ability/permission/smart_action_checker.rb
257
+ - app/services/forest_liana/aggregation_helper.rb
257
258
  - app/services/forest_liana/apimap_sorter.rb
258
259
  - app/services/forest_liana/authentication.rb
259
260
  - app/services/forest_liana/authorization_getter.rb
@@ -431,6 +432,7 @@ files:
431
432
  - spec/services/forest_liana/resources_getter_spec.rb
432
433
  - spec/services/forest_liana/schema_adapter_spec.rb
433
434
  - spec/services/forest_liana/scope_manager_spec.rb
435
+ - spec/services/forest_liana/serializer_factory_spec.rb
434
436
  - spec/services/forest_liana/smart_action_field_validator_spec.rb
435
437
  - spec/services/forest_liana/smart_action_form_parser_spec.rb
436
438
  - spec/services/forest_liana/utils/context_variables_injector_spec.rb
@@ -736,6 +738,7 @@ test_files:
736
738
  - spec/services/forest_liana/resources_getter_spec.rb
737
739
  - spec/services/forest_liana/schema_adapter_spec.rb
738
740
  - spec/services/forest_liana/scope_manager_spec.rb
741
+ - spec/services/forest_liana/serializer_factory_spec.rb
739
742
  - spec/services/forest_liana/smart_action_field_validator_spec.rb
740
743
  - spec/services/forest_liana/smart_action_form_parser_spec.rb
741
744
  - spec/services/forest_liana/utils/context_variables_injector_spec.rb