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 +4 -4
- data/app/services/forest_liana/aggregation_helper.rb +7 -7
- data/app/services/forest_liana/apimap_sorter.rb +7 -6
- data/app/services/forest_liana/leaderboard_stat_getter.rb +21 -12
- data/lib/forest_liana/version.rb +1 -1
- data/spec/services/forest_liana/apimap_sorter_spec.rb +74 -0
- data/spec/services/forest_liana/leaderboard_stat_getter_spec.rb +157 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f8365b7c92d4157a34acd7f00af2c3d515976b96884eef48c0a0e72c775b1ea0
|
|
4
|
+
data.tar.gz: a61cfdd476921b56e293014854693c3aa39ffd6d8474544273be222f91c5d0db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 ||=
|
|
6
|
-
return "#{
|
|
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 =
|
|
11
|
+
associated_resource = resource.reflect_on_association(association.to_sym)
|
|
12
12
|
"#{associated_resource.table_name}.#{field}"
|
|
13
13
|
else
|
|
14
|
-
"#{
|
|
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.
|
|
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.
|
|
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
|
-
|
|
9
|
-
@
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
.
|
|
35
|
+
.reorder(Arel.sql("#{alias_name} DESC"))
|
|
27
36
|
.limit(@limit)
|
|
28
|
-
.pluck(@group_by, Arel.sql("#{
|
|
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
|
|
46
|
+
return if scope_filters.blank?
|
|
38
47
|
|
|
39
48
|
FiltersParser.new(scope_filters, model, timezone, @params).apply_filters
|
|
40
49
|
end
|
data/lib/forest_liana/version.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|