Skip to content

Commit 370c852

Browse files
authored
fix: rails 8 compatibility for stat aggregators (#741)
1 parent 628a3e0 commit 370c852

File tree

4 files changed

+243
-42
lines changed

4 files changed

+243
-42
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
module ForestLiana
22
class LeaderboardStatGetter < StatGetter
3+
include AggregationHelper
4+
35
def initialize(parent_model, params, forest_user)
6+
@resource = parent_model
47
@scoped_parent_model = get_scoped_model(parent_model, forest_user, params[:timezone])
58
child_model = @scoped_parent_model.reflect_on_association(params[:relationshipFieldName]).klass
69
@scoped_child_model = get_scoped_model(child_model, forest_user, params[:timezone])
@@ -14,13 +17,15 @@ def initialize(parent_model, params, forest_user)
1417
def perform
1518
includes = ForestLiana::QueryHelper.get_one_association_names_symbol(@scoped_child_model)
1619

20+
alias_name = aggregation_alias(@aggregate, @aggregate_field)
21+
1722
result = @scoped_child_model
1823
.joins(includes)
1924
.where({ @scoped_parent_model.name.downcase.to_sym => @scoped_parent_model })
2025
.group(@group_by)
21-
.order(order)
26+
.order(Arel.sql("#{alias_name} DESC"))
2227
.limit(@limit)
23-
.send(@aggregate, @aggregate_field)
28+
.pluck(@group_by, Arel.sql("#{aggregation_sql(@aggregate, @aggregate_field)} AS #{alias_name}"))
2429
.map { |key, value| { key: key, value: value } }
2530

2631
@record = Model::Stat.new(value: result)
@@ -33,18 +38,5 @@ def get_scoped_model(model, forest_user, timezone)
3338

3439
FiltersParser.new(scope_filters, model, timezone, @params).apply_filters
3540
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
4941
end
5042
end
Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
module ForestLiana
22
class PieStatGetter < StatGetter
3+
include AggregationHelper
34
attr_accessor :record
45

56
def perform
@@ -13,11 +14,16 @@ def perform
1314
resource = FiltersParser.new(filters, resource, @params[:timezone], @params).apply_filters
1415
end
1516

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|
2127
# NOTICE: Display the enum name instead of an integer if it is an
2228
# "Enum" field type on old Rails version (before Rails
2329
# 5.1.3).
@@ -38,28 +44,7 @@ def perform
3844
end
3945

4046
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])
6248
end
63-
6449
end
6550
end

spec/services/forest_liana/pie_stat_getter_spec.rb

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,5 +133,187 @@ module ForestLiana
133133
end
134134
end
135135
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
136318
end
137319
end

0 commit comments

Comments
 (0)