forest_liana 9.22.7 → 9.22.9
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/serializers/forest_liana/serializer_factory.rb +7 -7
- data/app/services/forest_liana/ability/apimap_index.rb +44 -0
- data/app/services/forest_liana/ability/permission.rb +24 -6
- data/app/services/forest_liana/resources_getter.rb +3 -1
- data/lib/forest_liana/version.rb +1 -1
- data/spec/requests/projection_header_spec.rb +13 -0
- data/spec/services/forest_liana/ability/assert_can_read_query_fields_spec.rb +14 -0
- data/spec/services/forest_liana/ability/redact_fields_spec.rb +16 -0
- data/spec/services/forest_liana/resources_getter_spec.rb +13 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1427f49be68c4df6bbcd4fd859c13544c59d6c8c24b810cacfe2ebfcf34b20a0
|
|
4
|
+
data.tar.gz: 4987bbf28820237edab483d04752e068fe35e1a547b95784aa19cf2e70db17f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b0c64c511777459d427d6e33e75698004f96436894c668ffaf2333b63e3c8d539b354155708dd15c88ce1d1b8b6c856082df12bfa555455f14d43f2689c9223f
|
|
7
|
+
data.tar.gz: bf3bf6469ac14740c7b1eeddd6e05a404ffc7805248ee6bd4974f614c2c7e6b2e7be172743c3b55fa6bcad8bbf4da474ee5ed4306103e3ce4232e2b84d5edd92
|
|
@@ -137,7 +137,7 @@ module ForestLiana
|
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
def type
|
|
140
|
-
ForestLiana.name_for(object.class).demodulize
|
|
140
|
+
@type ||= ForestLiana.name_for(object.class).demodulize
|
|
141
141
|
end
|
|
142
142
|
|
|
143
143
|
def format_name(attribute_name)
|
|
@@ -155,8 +155,9 @@ module ForestLiana
|
|
|
155
155
|
def relationship_related_link(attribute_name)
|
|
156
156
|
ret = {}
|
|
157
157
|
|
|
158
|
-
# Has many smart field
|
|
159
|
-
|
|
158
|
+
# Has many smart field. Read off the class rather than has_many_relationships, which rebuilds
|
|
159
|
+
# every to-many on each call: once per serialized relation, so quadratic in their number.
|
|
160
|
+
current = self.class.to_many_associations&.[](attribute_name)
|
|
160
161
|
if current.try(:[], :options).try(:[], :name) == attribute_name
|
|
161
162
|
ret[:href] = "/forest/#{ForestLiana.name_for(object.class)}/#{object.id}/#{attribute_name}"
|
|
162
163
|
return ret
|
|
@@ -195,10 +196,9 @@ module ForestLiana
|
|
|
195
196
|
|
|
196
197
|
if ret[:href].blank?
|
|
197
198
|
begin
|
|
198
|
-
SchemaUtils.many_associations(object.class).
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
end
|
|
199
|
+
@many_association_names ||= SchemaUtils.many_associations(object.class).map(&:name)
|
|
200
|
+
if @many_association_names.include?(attribute_name)
|
|
201
|
+
ret[:href] = "/forest/#{ForestLiana.name_for(object.class)}/#{object.id}/relationships/#{attribute_name}"
|
|
202
202
|
end
|
|
203
203
|
rescue TypeError, ActiveRecord::StatementInvalid, NoMethodError => exception
|
|
204
204
|
FOREST_LOGGER.warn "Cannot load the association #{attribute_name} on #{object.class.name} #{object.id}.\n#{exception&.backtrace&.join("\n\t")}"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module ForestLiana
|
|
2
|
+
module Ability
|
|
3
|
+
# The apimap lookups a field-read check repeats once per requested field, made once per check
|
|
4
|
+
# instead: a wide collection on a large schema otherwise rescans every collection, and every
|
|
5
|
+
# field of the root one, for each field a page displays.
|
|
6
|
+
class ApimapIndex
|
|
7
|
+
def initialize(apimap)
|
|
8
|
+
@collections_by_name = apimap.each_with_object({}) { |collection, index| index[collection.name.to_s] ||= collection }
|
|
9
|
+
@collections_by_model = {}
|
|
10
|
+
@fields_by_name = {}
|
|
11
|
+
@smart_belongs_to_by_name = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def collection_for(model)
|
|
15
|
+
@collections_by_model.fetch(model) do
|
|
16
|
+
@collections_by_model[model] = @collections_by_name[ForestLiana.name_for(model)]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def exposed?(collection_name)
|
|
21
|
+
@collections_by_name.key?(collection_name)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fields_named(model, field_name)
|
|
25
|
+
@fields_by_name[model] ||= first_by_name(collection_for(model)&.fields.to_a, all: true)
|
|
26
|
+
@fields_by_name[model][field_name] || []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def smart_belongs_to(model, field_name)
|
|
30
|
+
@smart_belongs_to_by_name[model] ||= first_by_name(collection_for(model)&.fields_smart_belongs_to.to_a)
|
|
31
|
+
@smart_belongs_to_by_name[model][field_name]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def first_by_name(fields, all: false)
|
|
37
|
+
fields.each_with_object({}) do |field, index|
|
|
38
|
+
name = field[:field].to_s
|
|
39
|
+
all ? (index[name] ||= []) << field : index[name] ||= field
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -86,6 +86,10 @@ module ForestLiana
|
|
|
86
86
|
def redact_fields(user, root_model, fields_hash, named_collections:)
|
|
87
87
|
return fields_hash if fields_hash.nil?
|
|
88
88
|
|
|
89
|
+
with_apimap_index { redact_indexed_fields(user, root_model, fields_hash, named_collections) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def redact_indexed_fields(user, root_model, fields_hash, named_collections)
|
|
89
93
|
root_name = ForestLiana.name_for(root_model)
|
|
90
94
|
|
|
91
95
|
resolved = fields_hash.each_with_object({}) do |(collection_key, csv), acc|
|
|
@@ -131,6 +135,10 @@ module ForestLiana
|
|
|
131
135
|
# caller cannot read. +root_model+ is pinned readable — +browse+/+read+ already gate it
|
|
132
136
|
# upstream — so it is never itself a refusal.
|
|
133
137
|
def assert_can_read_query_fields(user, root_model, filter_paths: [], sort_paths: [], search_paths: [])
|
|
138
|
+
with_apimap_index { assert_indexed_query_fields(user, root_model, filter_paths, sort_paths, search_paths) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def assert_indexed_query_fields(user, root_model, filter_paths, sort_paths, search_paths)
|
|
134
142
|
root_name = ForestLiana.name_for(root_model)
|
|
135
143
|
|
|
136
144
|
usages = filter_paths.map { |path| query_usage('filter on', root_model, path) } +
|
|
@@ -352,29 +360,39 @@ module ForestLiana
|
|
|
352
360
|
end
|
|
353
361
|
|
|
354
362
|
def smart_belongs_to_field(model, field_name)
|
|
355
|
-
|
|
363
|
+
apimap_index.smart_belongs_to(model, field_name)
|
|
356
364
|
end
|
|
357
365
|
|
|
358
366
|
# SchemaAdapter drops a relation to an excluded model, but keeps one whose target is missing from
|
|
359
367
|
# the apimap otherwise (a table absent at boot, a secondary database unreachable): skipped alike.
|
|
360
368
|
# A polymorphic relation's reference names the association, never one of its targets, so it stays.
|
|
361
369
|
def only_computed_smart_field?(model, field_name, unexposed_targets)
|
|
362
|
-
fields =
|
|
363
|
-
fields = fields.reject do |field|
|
|
370
|
+
fields = apimap_index.fields_named(model, field_name).reject do |field|
|
|
364
371
|
!field[:is_virtual] && unexposed_targets.include?(field[:reference].to_s.split('.').first)
|
|
365
372
|
end
|
|
366
373
|
|
|
367
374
|
fields.any? && fields.all? { |field| field[:is_virtual] && field[:reference].nil? && field[:integration].nil? }
|
|
368
375
|
end
|
|
369
376
|
|
|
370
|
-
def
|
|
371
|
-
|
|
377
|
+
def apimap_index
|
|
378
|
+
@apimap_index || ApimapIndex.new(ForestLiana.apimap)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def with_apimap_index
|
|
382
|
+
return yield if @apimap_index
|
|
383
|
+
|
|
384
|
+
@apimap_index = ApimapIndex.new(ForestLiana.apimap)
|
|
385
|
+
begin
|
|
386
|
+
yield
|
|
387
|
+
ensure
|
|
388
|
+
@apimap_index = nil
|
|
389
|
+
end
|
|
372
390
|
end
|
|
373
391
|
|
|
374
392
|
# No role can ever be granted `read` on a collection absent from the apimap — a denial
|
|
375
393
|
# message naming it as unreadable would point at a permission nobody can grant.
|
|
376
394
|
def collection_exposed?(collection_name)
|
|
377
|
-
|
|
395
|
+
apimap_index.exposed?(collection_name)
|
|
378
396
|
end
|
|
379
397
|
|
|
380
398
|
# unexposed/also_denied (each present iff non-empty) tell UnauthorizedFieldsError which of
|
|
@@ -168,7 +168,9 @@ module ForestLiana
|
|
|
168
168
|
return [] unless @params.dig(:fields, @collection_name)
|
|
169
169
|
|
|
170
170
|
associations_for_query = extract_associations_from_filter
|
|
171
|
-
|
|
171
|
+
@params[:sort]&.split(',')&.each do |sort_field|
|
|
172
|
+
associations_for_query << sort_field.delete_prefix('-').split('.').first.to_sym if sort_field.include?('.')
|
|
173
|
+
end
|
|
172
174
|
@fields_to_serialize | associations_for_query
|
|
173
175
|
end
|
|
174
176
|
|
data/lib/forest_liana/version.rb
CHANGED
|
@@ -225,6 +225,19 @@ describe 'Requesting resources with the Forest-Projection header', :type => :req
|
|
|
225
225
|
expect(body['data']['relationships'].values.map(&:keys).uniq).to eq [%w[links]]
|
|
226
226
|
end
|
|
227
227
|
|
|
228
|
+
# has_many_relationships rebuilds every to-many of the record. Called once per link it is
|
|
229
|
+
# quadratic in the to-many count, which on a wide collection outweighs the whole get-one.
|
|
230
|
+
it 'builds the to-many relationships once, however many links the record carries' do
|
|
231
|
+
builds = 0
|
|
232
|
+
allow_any_instance_of(ForestLiana::UserSpace::UserSerializer).to receive(:has_many_relationships)
|
|
233
|
+
.and_wrap_original { |original| builds += 1; original.call }
|
|
234
|
+
|
|
235
|
+
get "/forest/User/#{@user.id}", headers: projecting('id,name')
|
|
236
|
+
|
|
237
|
+
expect(body['data']['relationships'].keys.size).to eq 7
|
|
238
|
+
expect(builds).to eq 1
|
|
239
|
+
end
|
|
240
|
+
|
|
228
241
|
# selects_from anchors on the query's own FROM, which a `selects_of('users')` filter would
|
|
229
242
|
# not: a to-many preload reads FROM "trees" and names "trees_users", never `"users"`, so it
|
|
230
243
|
# would be dropped before any assertion could see it.
|
|
@@ -67,6 +67,20 @@ module ForestLiana
|
|
|
67
67
|
expect { dummy_class.assert_can_read_query_fields(user, Tree) }.not_to raise_error
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
it 'reads the apimap once, however many paths are checked' do
|
|
71
|
+
write_permissions('Tree' => true, 'User' => true, 'Island' => true)
|
|
72
|
+
reads = 0
|
|
73
|
+
allow(ForestLiana).to receive(:apimap).and_wrap_original { |original| reads += 1; original.call }
|
|
74
|
+
|
|
75
|
+
expect do
|
|
76
|
+
dummy_class.assert_can_read_query_fields(
|
|
77
|
+
user, Tree, filter_paths: %w[owner:name island:name], sort_paths: %w[cutter:name name],
|
|
78
|
+
search_paths: %w[owner:title]
|
|
79
|
+
)
|
|
80
|
+
end.not_to raise_error
|
|
81
|
+
expect(reads).to eq 1
|
|
82
|
+
end
|
|
83
|
+
|
|
70
84
|
it 'serves a filter, a sort and a search on an unreadable collection once the option is on' do
|
|
71
85
|
write_permissions('Tree' => true, 'Island' => false)
|
|
72
86
|
ForestLiana.skip_relation_read_permissions = true
|
|
@@ -167,6 +167,22 @@ module ForestLiana
|
|
|
167
167
|
.to eq('Tree' => 'id,name,island', 'Island' => 'name')
|
|
168
168
|
end
|
|
169
169
|
|
|
170
|
+
# Resolving each field against the apimap rescans every collection: on a wide collection and
|
|
171
|
+
# a large schema, that cost outweighs the request it guards.
|
|
172
|
+
it 'reads the apimap once, however many fields are requested' do
|
|
173
|
+
write_permissions('Tree' => true, 'User' => true, 'Island' => true)
|
|
174
|
+
reads = 0
|
|
175
|
+
allow(ForestLiana).to receive(:apimap).and_wrap_original { |original| reads += 1; original.call }
|
|
176
|
+
|
|
177
|
+
redacted = dummy_class.redact_fields(
|
|
178
|
+
user, Tree, { 'Tree' => 'id,name,age,owner,cutter,island', 'owner' => 'name', 'island' => 'name' },
|
|
179
|
+
named_collections: %w[Tree owner island]
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
expect(redacted).to eq('Tree' => 'id,name,age,owner,cutter,island', 'owner' => 'name', 'island' => 'name')
|
|
183
|
+
expect(reads).to eq 1
|
|
184
|
+
end
|
|
185
|
+
|
|
170
186
|
it 'checks nothing at all when only root collection fields are requested' do
|
|
171
187
|
write_permissions({})
|
|
172
188
|
expect_any_instance_of(ForestLiana::Ability::Fetch).not_to receive(:get_permissions)
|
|
@@ -258,6 +258,19 @@ module ForestLiana
|
|
|
258
258
|
end
|
|
259
259
|
end
|
|
260
260
|
|
|
261
|
+
describe 'when sorting by a belongs_to association after another field' do
|
|
262
|
+
let(:resource) { Tree }
|
|
263
|
+
let(:fields) { { resource.name => 'id' } }
|
|
264
|
+
let(:sort) { 'id,-owner.name' }
|
|
265
|
+
|
|
266
|
+
it 'should load the association of the sort on a relation' do
|
|
267
|
+
getter.perform
|
|
268
|
+
|
|
269
|
+
expect(getter.includes).to contain_exactly(:owner)
|
|
270
|
+
expect(getter.records.map(&:id)).to eq [1, 2, 3, 4, 5]
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
261
274
|
describe 'when sorting by a has_one association' do
|
|
262
275
|
let(:resource) { Island }
|
|
263
276
|
let(:sort) { 'location.coordinates' }
|
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.9
|
|
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-10-
|
|
11
|
+
date: 2026-10-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -276,6 +276,7 @@ files:
|
|
|
276
276
|
- app/serializers/forest_liana/stripe_payment_serializer.rb
|
|
277
277
|
- app/serializers/forest_liana/stripe_subscription_serializer.rb
|
|
278
278
|
- app/services/forest_liana/ability.rb
|
|
279
|
+
- app/services/forest_liana/ability/apimap_index.rb
|
|
279
280
|
- app/services/forest_liana/ability/exceptions/access_denied.rb
|
|
280
281
|
- app/services/forest_liana/ability/exceptions/action_condition_error.rb
|
|
281
282
|
- app/services/forest_liana/ability/exceptions/approval_selection_too_large.rb
|