forest_liana 9.22.8 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 16535336fcd20313742ad4fc2bc231e159157d00f75fbb5a7cddb326be28507e
4
- data.tar.gz: e8f7d86078eb9d1b19b0c5fb923c26d144605914f556204c54ac631c8a84421c
3
+ metadata.gz: 1427f49be68c4df6bbcd4fd859c13544c59d6c8c24b810cacfe2ebfcf34b20a0
4
+ data.tar.gz: 4987bbf28820237edab483d04752e068fe35e1a547b95784aa19cf2e70db17f4
5
5
  SHA512:
6
- metadata.gz: f30b96a77ac786bda68265d8e8f4684a3141747e76096251c5c52f7f95883cb6539072bcb97bbdb796796e3292da67c8541ba7f454d1e6a359770833c5aa1c7b
7
- data.tar.gz: ec0e110843449b24f2fd4af2aae10b1ef43282ad01a7000de729e7dbaa135adb1eb7d490d71d4e5d06dad8adb110ea1f94843506f9984515e43657ff3536864a
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
- current = self.has_many_relationships[attribute_name]
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).each do |a|
199
- if a.name == attribute_name
200
- ret[:href] = "/forest/#{ForestLiana.name_for(object.class)}/#{object.id}/relationships/#{attribute_name}"
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
- forest_collection_for(model)&.fields_smart_belongs_to&.find { |field| field[:field].to_s == field_name }
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 = forest_collection_for(model)&.fields.to_a.select { |field| field[:field].to_s == field_name }
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 forest_collection_for(model)
371
- ForestLiana.apimap.find { |collection| collection.name.to_s == ForestLiana.name_for(model) }
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
- ForestLiana.apimap.any? { |collection| collection.name.to_s == collection_name }
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
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.22.8"
2
+ VERSION = "9.22.9"
3
3
  end
@@ -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)
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.8
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-07 00:00:00.000000000 Z
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