forest_liana 9.22.8 → 9.22.10

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: 23a1adeed07bf166b64953093122fc2e9c8c442d1ab52aec8227bec80f83f17d
4
+ data.tar.gz: 3cbea9923bf9350fbd8ad11c0a11479504c3a7b876b1c96e2c79a0cf602c88a2
5
5
  SHA512:
6
- metadata.gz: f30b96a77ac786bda68265d8e8f4684a3141747e76096251c5c52f7f95883cb6539072bcb97bbdb796796e3292da67c8541ba7f454d1e6a359770833c5aa1c7b
7
- data.tar.gz: ec0e110843449b24f2fd4af2aae10b1ef43282ad01a7000de729e7dbaa135adb1eb7d490d71d4e5d06dad8adb110ea1f94843506f9984515e43657ff3536864a
6
+ metadata.gz: d41d2034547eb686df156ac01a72cd9e85f3aa10fcaf272831013dcf0827f7bd1b216b35828b22cc699d88e6ed1541ddaae5769b76e16cf6caf6aace4a49edef
7
+ data.tar.gz: f26b8e3fb84c18d3ee198b0f474e2bf189254b0c47a32365ee775ffcb1652baee2c6eea641e22cdb753d3f552ef5cc360db7b6c76a3df3f5eef8357adec0ede0
@@ -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
@@ -31,6 +31,18 @@ module ForestLiana
31
31
  end
32
32
 
33
33
  def self.find_model_from_collection_name(collection_name, logs = false)
34
+ model_found = LookupCache.fetch(:model_from_collection_name, collection_name) do
35
+ scan_models_for_collection_name(collection_name)
36
+ end
37
+
38
+ if logs && model_found.nil?
39
+ FOREST_LOGGER.warn "No model found for collection #{collection_name}"
40
+ end
41
+
42
+ model_found
43
+ end
44
+
45
+ def self.scan_models_for_collection_name(collection_name)
34
46
  model_found = nil
35
47
  ForestLiana.models.each do |model|
36
48
  if model.abstract_class?
@@ -42,10 +54,6 @@ module ForestLiana
42
54
  break if model_found
43
55
  end
44
56
 
45
- if logs && model_found.nil?
46
- FOREST_LOGGER.warn "No model found for collection #{collection_name}"
47
- end
48
-
49
57
  model_found
50
58
  end
51
59
 
@@ -0,0 +1,37 @@
1
+ module ForestLiana
2
+ # Remembers lookups by name over ForestLiana.models, .apimap and .names_overriden: on a large
3
+ # schema each one is a linear scan, schema_for_resource a scan of scans, and every request runs
4
+ # several. Any of the three being replaced or growing (the bootstrapper, a Forest collection
5
+ # file, a code reload) drops everything remembered, so an answer never outlives what it read.
6
+ module LookupCache
7
+ # A miss is not remembered: the router resolves a request's collection before authenticating
8
+ # it, so caching unknown names would let any caller grow this cache without bound.
9
+ def self.fetch(namespace, key)
10
+ reset_if_stale
11
+ store = (@stores[namespace] ||= {})
12
+ return store[key] if store.key?(key)
13
+
14
+ value = yield
15
+ store[key] = value unless value.nil?
16
+ value
17
+ end
18
+
19
+ def self.reset!
20
+ @stores = nil
21
+ end
22
+
23
+ def self.reset_if_stale
24
+ models = ForestLiana.models
25
+ apimap = ForestLiana.apimap
26
+ names = ForestLiana.names_overriden
27
+ return if @stores && models.equal?(@models) && models.size == @models_size &&
28
+ apimap.equal?(@apimap) && apimap.size == @apimap_size &&
29
+ names.equal?(@names) && names.size == @names_size
30
+
31
+ @models, @models_size = models, models.size
32
+ @apimap, @apimap_size = apimap, apimap.size
33
+ @names, @names_size = names, names.size
34
+ @stores = {}
35
+ end
36
+ end
37
+ end
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.22.8"
2
+ VERSION = "9.22.10"
3
3
  end
data/lib/forest_liana.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require 'forest_liana/engine'
2
+ require 'forest_liana/lookup_cache'
2
3
 
3
4
  module Forest
4
5
  end
@@ -69,14 +70,16 @@ module ForestLiana
69
70
  end
70
71
 
71
72
  def self.schema_for_resource resource
72
- self.apimap.find do |collection|
73
- SchemaUtils.find_model_from_collection_name(collection.name)
74
- .try(:name) == resource.name
73
+ LookupCache.fetch(:schema_for_resource, resource.name) do
74
+ self.apimap.find do |collection|
75
+ SchemaUtils.find_model_from_collection_name(collection.name)
76
+ .try(:name) == resource.name
77
+ end
75
78
  end
76
79
  end
77
80
 
78
81
  def self.name_for(model)
79
- self.names_overriden[model] || model.try(:name).gsub('::', '__')
82
+ LookupCache.fetch(:name_for, model) { self.names_overriden[model] || model.try(:name).gsub('::', '__') }
80
83
  end
81
84
 
82
85
  # TODO: Remove once lianas prior to 2.0.0 are not supported anymore.
@@ -0,0 +1,68 @@
1
+ require 'rails_helper'
2
+
3
+ describe 'Lookups by name over the schema' do
4
+ describe 'ForestLiana.name_for' do
5
+ after { ForestLiana.names_overriden.delete(Tree) }
6
+
7
+ it 'answers an override registered after a first lookup' do
8
+ expect(ForestLiana.name_for(Tree)).to eq 'Tree'
9
+
10
+ ForestLiana.names_overriden[Tree] = 'Orchard'
11
+
12
+ expect(ForestLiana.name_for(Tree)).to eq 'Orchard'
13
+ end
14
+ end
15
+
16
+ describe 'ForestLiana::SchemaUtils.find_model_from_collection_name' do
17
+ let(:ghost) { Class.new(ActiveRecord::Base) { self.table_name = 'trees' } }
18
+
19
+ after do
20
+ ForestLiana.models.delete(ghost)
21
+ ForestLiana.names_overriden.delete(ghost)
22
+ end
23
+
24
+ it 'scans the models once for repeated lookups of a name' do
25
+ expect(ForestLiana::SchemaUtils).to receive(:scan_models_for_collection_name).with('Tree').once.and_call_original
26
+
27
+ 3.times { expect(ForestLiana::SchemaUtils.find_model_from_collection_name('Tree')).to eq Tree }
28
+ end
29
+
30
+ it 'does not remember an unknown name, which any caller can send before authenticating' do
31
+ expect(ForestLiana::SchemaUtils).to receive(:scan_models_for_collection_name).with('Ghost').twice.and_call_original
32
+
33
+ 2.times { expect(ForestLiana::SchemaUtils.find_model_from_collection_name('Ghost')).to be_nil }
34
+ end
35
+
36
+ it 'still warns on each lookup of an unknown name' do
37
+ expect(FOREST_LOGGER).to receive(:warn).with('No model found for collection Ghost').twice
38
+
39
+ 2.times { expect(ForestLiana::SchemaUtils.find_model_from_collection_name('Ghost', true)).to be_nil }
40
+ end
41
+
42
+ it 'finds a model added after a first lookup missed it' do
43
+ expect(ForestLiana::SchemaUtils.find_model_from_collection_name('Ghost')).to be_nil
44
+
45
+ ForestLiana.names_overriden[ghost] = 'Ghost'
46
+ ForestLiana.models << ghost
47
+
48
+ expect(ForestLiana::SchemaUtils.find_model_from_collection_name('Ghost')).to eq ghost
49
+ end
50
+ end
51
+
52
+ describe 'ForestLiana.schema_for_resource' do
53
+ it 'resolves the collection of a resource once for repeated lookups' do
54
+ expect(ForestLiana.schema_for_resource(Tree).name).to eq 'Tree'
55
+ expect(ForestLiana::SchemaUtils).not_to receive(:find_model_from_collection_name)
56
+
57
+ expect(ForestLiana.schema_for_resource(Tree).name).to eq 'Tree'
58
+ end
59
+
60
+ it 'answers from the apimap in place when it is replaced' do
61
+ expect(ForestLiana.schema_for_resource(Tree).name).to eq 'Tree'
62
+ replacement = ForestLiana::Model::Collection.new(name: 'Tree', fields: [])
63
+ allow(ForestLiana).to receive(:apimap).and_return([replacement])
64
+
65
+ expect(ForestLiana.schema_for_resource(Tree)).to equal replacement
66
+ end
67
+ end
68
+ end
data/spec/rails_helper.rb CHANGED
@@ -65,6 +65,7 @@ RSpec.configure do |config|
65
65
  # own comment) — cleared before every example so one spec's warning can't silence another's,
66
66
  # regardless of run order or which file exercises the valve.
67
67
  config.before { ForestLiana::MissingAttributeValve::WARNED_ONCE.clear }
68
+ config.before { ForestLiana::LookupCache.reset! }
68
69
 
69
70
  # Filter lines from Rails gems in backtraces.
70
71
  config.filter_rails_from_backtrace!
@@ -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,18 @@ 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 'indexes the apimap once, however many paths are checked' do
71
+ write_permissions('Tree' => true, 'User' => true, 'Island' => true)
72
+ expect(ForestLiana::Ability::ApimapIndex).to receive(:new).with(ForestLiana.apimap).once.and_call_original
73
+
74
+ expect do
75
+ dummy_class.assert_can_read_query_fields(
76
+ user, Tree, filter_paths: %w[owner:name island:name], sort_paths: %w[cutter:name name],
77
+ search_paths: %w[owner:title]
78
+ )
79
+ end.not_to raise_error
80
+ end
81
+
70
82
  it 'serves a filter, a sort and a search on an unreadable collection once the option is on' do
71
83
  write_permissions('Tree' => true, 'Island' => false)
72
84
  ForestLiana.skip_relation_read_permissions = true
@@ -167,6 +167,20 @@ 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 'indexes the apimap once, however many fields are requested' do
173
+ write_permissions('Tree' => true, 'User' => true, 'Island' => true)
174
+ expect(ForestLiana::Ability::ApimapIndex).to receive(:new).with(ForestLiana.apimap).once.and_call_original
175
+
176
+ redacted = dummy_class.redact_fields(
177
+ user, Tree, { 'Tree' => 'id,name,age,owner,cutter,island', 'owner' => 'name', 'island' => 'name' },
178
+ named_collections: %w[Tree owner island]
179
+ )
180
+
181
+ expect(redacted).to eq('Tree' => 'id,name,age,owner,cutter,island', 'owner' => 'name', 'island' => 'name')
182
+ end
183
+
170
184
  it 'checks nothing at all when only root collection fields are requested' do
171
185
  write_permissions({})
172
186
  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.10
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
@@ -364,6 +365,7 @@ files:
364
365
  - lib/forest_liana/collection.rb
365
366
  - lib/forest_liana/engine.rb
366
367
  - lib/forest_liana/json_printer.rb
368
+ - lib/forest_liana/lookup_cache.rb
367
369
  - lib/forest_liana/mixpanel_event.rb
368
370
  - lib/forest_liana/schema_file_updater.rb
369
371
  - lib/forest_liana/version.rb
@@ -470,6 +472,7 @@ files:
470
472
  - spec/lib/forest_liana/bootstrapper_spec.rb
471
473
  - spec/lib/forest_liana/collection_spec.rb
472
474
  - spec/lib/forest_liana/engine_spec.rb
475
+ - spec/lib/forest_liana/lookup_cache_spec.rb
473
476
  - spec/lib/forest_liana/schema_file_updater_spec.rb
474
477
  - spec/models/forest_liana/model/action_spec.rb
475
478
  - spec/models/forest_liana/model/collection_spec.rb
@@ -828,6 +831,7 @@ test_files:
828
831
  - spec/lib/forest_liana/bootstrapper_spec.rb
829
832
  - spec/lib/forest_liana/collection_spec.rb
830
833
  - spec/lib/forest_liana/engine_spec.rb
834
+ - spec/lib/forest_liana/lookup_cache_spec.rb
831
835
  - spec/lib/forest_liana/schema_file_updater_spec.rb
832
836
  - spec/models/forest_liana/model/action_spec.rb
833
837
  - spec/models/forest_liana/model/collection_spec.rb