forest_liana 9.22.11 → 9.22.13

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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/forest_liana/actions_controller.rb +10 -0
  3. data/app/controllers/forest_liana/application_controller.rb +13 -2
  4. data/app/helpers/forest_liana/decoration_helper.rb +9 -3
  5. data/app/helpers/forest_liana/query_helper.rb +12 -9
  6. data/app/serializers/forest_liana/serializer_factory.rb +12 -3
  7. data/app/services/forest_liana/ability/permission.rb +50 -14
  8. data/app/services/forest_liana/base_getter.rb +1 -2
  9. data/app/services/forest_liana/request_cache.rb +28 -0
  10. data/app/services/forest_liana/resources_getter.rb +1 -1
  11. data/app/services/forest_liana/schema_utils.rb +13 -7
  12. data/app/services/forest_liana/scope_manager.rb +12 -9
  13. data/app/services/forest_liana/search_query_builder.rb +6 -3
  14. data/app/services/forest_liana/single_record_selection.rb +11 -0
  15. data/app/services/forest_liana/utils/context_variables_injector.rb +1 -1
  16. data/lib/forest_liana/lookup_cache.rb +30 -15
  17. data/lib/forest_liana/version.rb +1 -1
  18. data/spec/helpers/forest_liana/decoration_helper_spec.rb +45 -2
  19. data/spec/lib/forest_liana/lookup_cache_spec.rb +113 -0
  20. data/spec/requests/actions_controller_spec.rb +79 -0
  21. data/spec/requests/permissions_cache_footprint_spec.rb +122 -0
  22. data/spec/requests/schema_lookup_footprint_spec.rb +62 -0
  23. data/spec/services/forest_liana/ability/permission_spec.rb +74 -0
  24. data/spec/services/forest_liana/request_cache_spec.rb +50 -0
  25. data/spec/services/forest_liana/resources_getter_spec.rb +17 -0
  26. data/spec/services/forest_liana/scope_manager_spec.rb +16 -1
  27. data/spec/services/forest_liana/search_query_builder_spec.rb +30 -0
  28. data/spec/services/forest_liana/serializer_factory_spec.rb +15 -0
  29. metadata +10 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 345a74b2a7088f0c84717c3023f50fef3c0918db97d392e936047abfb57a3b48
4
- data.tar.gz: 9abe4f90a54bddf3df9cf4cd73db258ef60962ec9bf5353904dd916676b15aac
3
+ metadata.gz: fcbf97d59c4b454c53e5f068f969e8e503e2a722c1a011e2d03b7f9e73a0c152
4
+ data.tar.gz: cd6608d3c224d6daaedd240928a34cec77d49c35bfdf52fdb0d5b5187a23cf02
5
5
  SHA512:
6
- metadata.gz: 1f096e618b492ecea05c88063f3deb647a8928deaf524c95b35572061db3c7094c34484c492134dfdcf18d52f0897d7fa9d9f1a645ff419cfbb18489681532c0
7
- data.tar.gz: 2d818ad0f0e90d95f5574643a2b50d94615ec0f5601b9e10d8477f250ddd6e0ab9c34f6a23d0d9e470f69dbb76861fddf99b26a45b20449262a0251b08eaada6
6
+ metadata.gz: 78dd9047c6f8647076342d0a99f223ad66c7c33590b6b8be4726901d4aa6b057eb97eb4137cd6de3852fcf06d505621b77e6d6b653bb699443e753b57955a782
7
+ data.tar.gz: c0dc2372c158fb2f86929279b3594ae6aec5b2270ae6fd74a19871c7dc26af89b0b20d8d5f8a822ddd6147ab11d89413f80df582168ea31e41e7fac847c2e430
@@ -47,6 +47,12 @@ module ForestLiana
47
47
  {:field_changed => found_field_changed, :fields => fields, :params => params, :user => forest_user}
48
48
  end
49
49
 
50
+ def ensure_single_record_selection(action, attributes)
51
+ return if SingleRecordSelection.allowed?(action, attributes)
52
+
53
+ raise ForestLiana::Errors::HTTP422Error.new(SingleRecordSelection::MESSAGE)
54
+ end
55
+
50
56
  def handle_result(result, action)
51
57
  if result.nil? || !result.is_a?(Array)
52
58
  return render status: 500, json: { error: 'Error in smart action load hook: hook must return an array of fields' }
@@ -104,6 +110,8 @@ module ForestLiana
104
110
  if !action
105
111
  render status: 500, json: {error: 'Error in smart action load hook: cannot retrieve action from collection'}
106
112
  else
113
+ ensure_single_record_selection(action, load_request)
114
+
107
115
  # Get the smart action hook load context
108
116
  context = get_smart_action_load_ctx(action.fields)
109
117
 
@@ -132,6 +140,8 @@ module ForestLiana
132
140
  return render status: 500, json: {error: 'Error in smart action change hook: fields params must be an array'}
133
141
  end
134
142
 
143
+ ensure_single_record_selection(action, change_request)
144
+
135
145
  # Get the smart action hook change context
136
146
  context = get_smart_action_change_ctx(change_request[:fields], change_request[:changed_field])
137
147
 
@@ -27,6 +27,8 @@ module ForestLiana
27
27
  Object.const_get('PaperTrail::Version').is_a?(Class) rescue false
28
28
  end
29
29
 
30
+ around_action :with_request_cache
31
+
30
32
  # NOTICE: Calling the method set_paper_trail_whodunnit loads the PaperTrail
31
33
  # gem and Forest detects the PaperTrail::Version automatically. This
32
34
  # method is used to set the whodunnit field automatically to track
@@ -131,6 +133,10 @@ module ForestLiana
131
133
 
132
134
  private
133
135
 
136
+ def with_request_cache(&block)
137
+ ForestLiana::RequestCache.with(&block)
138
+ end
139
+
134
140
  # NOTICE: What survives becomes(), which allocates a fresh instance and copies @attributes
135
141
  # onto it and nothing else. Shared by both controllers' get_record, unlike the rest of
136
142
  # their rendering, because getting it wrong is a correctness bug rather than a
@@ -256,11 +262,16 @@ module ForestLiana
256
262
  params_fields_hash = params_fields.to_hash
257
263
  end
258
264
 
265
+ forest_collection = nil
266
+ smart_relations = nil
259
267
  params_fields_hash.inject({}) do |fields, param_field|
260
268
  relation_name = param_field[0]
261
269
  relation_fields = param_field[1]
262
- forest_collection = ForestLiana.apimap.find { |collection| collection.name.to_s == model.to_s.gsub('::', '__') }
263
- smart_relations = forest_collection.fields_smart_belongs_to
270
+ forest_collection ||= begin
271
+ collection_name = model.to_s.gsub('::', '__')
272
+ ForestLiana.apimap.find { |collection| collection.name.to_s == collection_name }
273
+ end
274
+ smart_relations ||= forest_collection.fields_smart_belongs_to
264
275
 
265
276
  if relation_name == ForestLiana.name_for(model)
266
277
  fields[relation_name] = relation_fields
@@ -2,7 +2,7 @@ module ForestLiana
2
2
  module DecorationHelper
3
3
  def self.detect_match_and_decorate record, index, field_name, value, search_value, match_fields
4
4
  begin
5
- match = value.match(/#{search_value}/i)
5
+ match = value.match(search_value.is_a?(Regexp) ? search_value : compile_search_pattern(search_value))
6
6
  if match
7
7
  match_fields[index] = { id: record['id'], search: [] } if match_fields[index].nil?
8
8
  match_fields[index][:search] << field_name
@@ -12,6 +12,7 @@ module ForestLiana
12
12
  end
13
13
 
14
14
  def self.decorate_for_search(records_serialized, field_names, search_value)
15
+ search_pattern = compile_search_pattern(search_value)
15
16
  match_fields = {}
16
17
  records_serialized['data'].each_with_index do |record, index|
17
18
  unless record['attributes']
@@ -21,14 +22,19 @@ module ForestLiana
21
22
  field_names.each do |field_name|
22
23
  value = record['attributes'][field_name]
23
24
  if value
24
- detect_match_and_decorate(record, index, field_name, value, search_value, match_fields)
25
+ detect_match_and_decorate(record, index, field_name, value, search_pattern, match_fields)
25
26
  end
26
27
  end
27
28
 
28
- detect_match_and_decorate(record, index, 'id', record['id'], search_value, match_fields)
29
+ detect_match_and_decorate(record, index, 'id', record['id'], search_pattern, match_fields)
29
30
  end
30
31
  match_fields.empty? ? nil : match_fields
31
32
  end
32
33
 
34
+ # Literal, like the LIKE that found the records: a regex metacharacter in the search must not
35
+ # highlight what the query never matched, nor raise and highlight nothing.
36
+ def self.compile_search_pattern(search_value)
37
+ Regexp.new(Regexp.escape(search_value.to_s), Regexp::IGNORECASE)
38
+ end
33
39
  end
34
40
  end
@@ -1,16 +1,19 @@
1
1
  module ForestLiana
2
2
  module QueryHelper
3
3
  def self.get_one_associations(resource)
4
- associations = SchemaUtils.one_associations(resource)
5
- .select do |association|
6
- if SchemaUtils.polymorphic?(association)
7
- SchemaUtils.polymorphic_models(association).all? { |model| SchemaUtils.model_included?(model) }
8
- else
9
- SchemaUtils.model_included?(association.klass)
10
- end
11
- end
4
+ model = resource.is_a?(ActiveRecord::Relation) ? resource.klass : resource
5
+
6
+ LookupCache.fetch(:one_associations, model) { scan_one_associations(model).freeze }
7
+ end
12
8
 
13
- associations
9
+ def self.scan_one_associations(resource)
10
+ SchemaUtils.one_associations(resource).select do |association|
11
+ if SchemaUtils.polymorphic?(association)
12
+ SchemaUtils.polymorphic_models(association).all? { |model| SchemaUtils.model_included?(model) }
13
+ else
14
+ SchemaUtils.model_included?(association.klass)
15
+ end
16
+ end
14
17
  end
15
18
 
16
19
  def self.get_one_association_names_symbol(resource)
@@ -2,6 +2,8 @@ require 'jsonapi-serializers'
2
2
 
3
3
  module ForestLiana
4
4
  class SerializerFactory
5
+ # Keyed by the fields array itself, which serialize builds anew on each call.
6
+ FIELD_SETS = ObjectSpace::WeakMap.new
5
7
 
6
8
  def self.define_serializer(active_record_class, serializer)
7
9
  serializer_name = self.build_serializer_name(active_record_class)
@@ -212,7 +214,7 @@ module ForestLiana
212
214
  return {} if self.class.to_one_associations.nil?
213
215
  data = {}
214
216
  self.class.to_one_associations.each do |attribute_name, attr_data|
215
- relation = object.class.reflect_on_all_associations.find { |a| a.name == attribute_name }
217
+ relation = object.class.reflect_on_association(attribute_name)
216
218
  next if !should_include_attr?(attribute_name, attr_data)
217
219
 
218
220
  if relation && relation.belongs_to? && relation.polymorphic?.nil?
@@ -252,7 +254,7 @@ module ForestLiana
252
254
  collection = self.type
253
255
 
254
256
  unless @options.dig(:context, :unoptimized)
255
- return false unless @options[:fields][collection]&.include?(attribute_name.to_sym)
257
+ return false unless field_set(collection)&.include?(attribute_name.to_sym)
256
258
  end
257
259
 
258
260
  # Allow "if: :show_title?" and "unless: :hide_title?" attribute options.
@@ -262,12 +264,19 @@ module ForestLiana
262
264
  show_attr = true
263
265
  show_attr &&= send(if_method_name) if if_method_name
264
266
  show_attr &&= !send(unless_method_name) if unless_method_name
265
- show_attr &&= @_fields[type.to_s].include?(formatted_attribute_name) if @_fields[type.to_s]
267
+ show_attr &&= field_set(type.to_s).include?(formatted_attribute_name) if @_fields[type.to_s]
266
268
  show_attr
267
269
  end
268
270
 
269
271
  private
270
272
 
273
+ def field_set(collection)
274
+ fields = @_fields[collection]
275
+ return nil if fields.nil?
276
+
277
+ FIELD_SETS[fields] ||= Set.new(fields)
278
+ end
279
+
271
280
  def intercom_integration?
272
281
  ForestLiana.integrations
273
282
  .try(:[], :intercom)
@@ -35,6 +35,7 @@ module ForestLiana
35
35
  end
36
36
 
37
37
  def is_smart_action_authorized?(user, collection, parameters, endpoint, http_method)
38
+ ensure_single_record_selection(collection, parameters, endpoint, http_method)
38
39
  return true unless has_permission_system?
39
40
 
40
41
  user_data = get_user_data(user['id'])
@@ -54,8 +55,8 @@ module ForestLiana
54
55
 
55
56
  # +refetch+ drops the cached permissions first. Only a caller about to refuse on a denial asks
56
57
  # for it (a stale, up to TTL-old cache may sit behind a permission granted moments ago): the
57
- # refetch deletes the cluster-wide `forest.collections` entry, so a field that is merely
58
- # redacted is answered from the cache.
58
+ # refetch rewrites the cluster-wide `forest.collections` entry and this request's copy, so a
59
+ # field that is merely redacted is answered from the cache.
59
60
  def read_permissions(user, collection_names, refetch: false)
60
61
  @read_permissions_cache ||= {}
61
62
  to_fetch = refetch ? collection_names.uniq : collection_names.uniq - @read_permissions_cache.keys
@@ -251,17 +252,20 @@ module ForestLiana
251
252
  end
252
253
 
253
254
  def get_user_data(user_id, force_fetch = true)
254
- cache = Rails.cache.fetch('forest.users', expires_in: TTL) do
255
- users = {}
256
- get_permissions('/liana/v4/permissions/users').each do |user|
257
- users[user['id'].to_s] = user
258
- end
255
+ cache = RequestCache.fetch('forest.users') do
256
+ Rails.cache.fetch('forest.users', expires_in: TTL) do
257
+ users = {}
258
+ get_permissions('/liana/v4/permissions/users').each do |user|
259
+ users[user['id'].to_s] = user
260
+ end
259
261
 
260
- users
262
+ users
263
+ end
261
264
  end
262
265
 
263
266
  if !cache.key?(user_id.to_s) && force_fetch
264
267
  Rails.cache.delete('forest.users')
268
+ RequestCache.delete('forest.users')
265
269
  get_user_data(user_id, false)
266
270
  else
267
271
  cache[user_id.to_s]
@@ -273,9 +277,15 @@ module ForestLiana
273
277
  # followed failed, so during a Forest API incident every pod re-hit the dead API on every
274
278
  # request instead of riding on the last good payload until the TTL ran out.
275
279
  def get_collections_permissions_data(force_fetch = false)
276
- Rails.cache.write('forest.collections', fetch_collections_permissions, expires_in: TTL) if force_fetch == true
280
+ if force_fetch == true
281
+ collections = fetch_collections_permissions
282
+ Rails.cache.write('forest.collections', collections, expires_in: TTL)
283
+ return RequestCache.write('forest.collections', collections)
284
+ end
277
285
 
278
- Rails.cache.fetch('forest.collections', expires_in: TTL) { fetch_collections_permissions }
286
+ RequestCache.fetch('forest.collections') do
287
+ Rails.cache.fetch('forest.collections', expires_in: TTL) { fetch_collections_permissions }
288
+ end
279
289
  end
280
290
 
281
291
  def fetch_collections_permissions
@@ -285,9 +295,15 @@ module ForestLiana
285
295
  end
286
296
 
287
297
  def get_chart_data(rendering_id, force_fetch = false)
288
- Rails.cache.write('forest.stats', fetch_chart_data(rendering_id), expires_in: TTL) if force_fetch == true
298
+ if force_fetch == true
299
+ stats = fetch_chart_data(rendering_id)
300
+ Rails.cache.write('forest.stats', stats, expires_in: TTL)
301
+ return RequestCache.write('forest.stats', stats)
302
+ end
289
303
 
290
- Rails.cache.fetch('forest.stats', expires_in: TTL) { fetch_chart_data(rendering_id) }
304
+ RequestCache.fetch('forest.stats') do
305
+ Rails.cache.fetch('forest.stats', expires_in: TTL) { fetch_chart_data(rendering_id) }
306
+ end
291
307
  end
292
308
 
293
309
  def fetch_chart_data(rendering_id)
@@ -297,8 +313,10 @@ module ForestLiana
297
313
  end
298
314
 
299
315
  def has_permission_system?
300
- Rails.cache.fetch('forest.has_permission') do
301
- (get_permissions('/liana/v4/permissions/environment') == true) ? false : true
316
+ RequestCache.fetch('forest.has_permission') do
317
+ Rails.cache.fetch('forest.has_permission') do
318
+ (get_permissions('/liana/v4/permissions/environment') == true) ? false : true
319
+ end
302
320
  end
303
321
  end
304
322
 
@@ -331,6 +349,24 @@ module ForestLiana
331
349
  actions
332
350
  end
333
351
 
352
+ def ensure_single_record_selection(collection, parameters, endpoint, http_method)
353
+ schema_action = find_action_from_endpoint(ForestLiana.name_for(collection), endpoint, http_method)
354
+ return unless schema_action&.type == 'single'
355
+
356
+ selections = [parameters[:data][:attributes], signed_approval_attributes(parameters)].compact
357
+ return if selections.all? { |attributes| SingleRecordSelection.allowed?(schema_action, attributes) }
358
+
359
+ raise ForestLiana::Errors::HTTP422Error.new(SingleRecordSelection::MESSAGE)
360
+ end
361
+
362
+ def signed_approval_attributes(parameters)
363
+ return unless parameters[:data][:attributes][:signed_approval_request]
364
+
365
+ RequestPermission.decodeSignedApprovalRequest(parameters)[:data][:attributes]
366
+ rescue JWT::DecodeError
367
+ nil
368
+ end
369
+
334
370
  def find_action_from_endpoint(collection_name, endpoint, http_method)
335
371
  collection = ForestLiana.apimap.find { |collection| collection.name.to_s == collection_name }
336
372
 
@@ -777,8 +777,7 @@ module ForestLiana
777
777
  # Handle composite primary keys - name might be an Array
778
778
  name_sym = name.is_a?(Array) ? name : name.to_sym
779
779
  ForestLiana::QueryHelper.get_one_associations(projected_resource)
780
- .select { |association| association.name == name_sym }
781
- .first
780
+ .find { |association| association.name == name_sym }
782
781
  end
783
782
 
784
783
  def is_active_storage_association?(association)
@@ -0,0 +1,28 @@
1
+ module ForestLiana
2
+ # Keeps what a Forest request reads from Rails.cache for the rest of that request: each read
3
+ # there deserializes the whole payload again. Off outside +with+, where every call goes straight
4
+ # to Rails.cache as before.
5
+ class RequestCache < ActiveSupport::CurrentAttributes
6
+ attribute :store
7
+
8
+ def self.with(&block)
9
+ set(store: {}, &block)
10
+ end
11
+
12
+ def self.fetch(key)
13
+ return yield if store.nil?
14
+ return store[key] if store.key?(key)
15
+
16
+ store[key] = yield
17
+ end
18
+
19
+ def self.write(key, value)
20
+ store[key] = value unless store.nil?
21
+ value
22
+ end
23
+
24
+ def self.delete(key)
25
+ store&.delete(key)
26
+ end
27
+ end
28
+ end
@@ -235,7 +235,7 @@ module ForestLiana
235
235
 
236
236
  begin
237
237
  segment_query = @params[:segmentQuery].gsub(/\;\s*$/, '')
238
- ScopeManager.inject_context_variables_on_query(segment_query, @user)
238
+ segment_query = ScopeManager.inject_context_variables_on_query(segment_query, @user)
239
239
 
240
240
  @records = @records.where(
241
241
  "#{@resource.table_name}.#{@resource.primary_key} IN (SELECT id FROM (#{segment_query}) as ids)"
@@ -2,6 +2,12 @@ module ForestLiana
2
2
  class SchemaUtils
3
3
 
4
4
  def self.associations(active_record_class)
5
+ model = active_record_class.is_a?(ActiveRecord::Relation) ? active_record_class.klass : active_record_class
6
+
7
+ LookupCache.fetch(:associations, model) { scan_associations(model).freeze }
8
+ end
9
+
10
+ def self.scan_associations(active_record_class)
5
11
  active_record_class.reflect_on_all_associations.select do |association|
6
12
  begin
7
13
  polymorphic?(association) ? true : !is_active_type?(association.klass)
@@ -89,14 +95,14 @@ module ForestLiana
89
95
  end
90
96
 
91
97
  def self.polymorphic_models(relation)
92
- models = []
93
- ForestLiana.models.each do |model|
94
- unless model.reflect_on_all_associations.select { |association| association.options[:as] == relation.name.to_sym }.empty?
95
- models << model
96
- end
97
- end
98
+ name = relation.name.to_sym
99
+ LookupCache.fetch(:polymorphic_models, name) { scan_polymorphic_models(name).freeze }
100
+ end
98
101
 
99
- models
102
+ def self.scan_polymorphic_models(name)
103
+ ForestLiana.models.select do |model|
104
+ model.reflect_on_all_associations.any? { |association| association.options[:as] == name }
105
+ end
100
106
  end
101
107
 
102
108
 
@@ -64,18 +64,21 @@ module ForestLiana
64
64
 
65
65
  def self.invalidate_scope_cache(rendering_id)
66
66
  Rails.cache.delete('forest.scopes.' + rendering_id.to_s)
67
+ RequestCache.delete("forest.scopes.#{rendering_id}")
67
68
  end
68
69
 
69
70
  def self.fetch_scopes(rendering_id)
70
- Rails.cache.fetch("forest.scopes.#{rendering_id}", expires_in: @@scope_cache_expiration_delta) do
71
- response = ForestLiana::ForestApiRequester.get("/liana/v4/permissions/renderings/#{rendering_id}")
72
- raise 'Unable to fetch scopes' unless response.is_a?(Net::HTTPOK)
73
- parsed_response = JSON.parse(response.body)
74
-
75
- {
76
- 'scopes' => decode_scope(parsed_response['collections']),
77
- 'team' => parsed_response['team']
78
- }
71
+ RequestCache.fetch("forest.scopes.#{rendering_id}") do
72
+ Rails.cache.fetch("forest.scopes.#{rendering_id}", expires_in: @@scope_cache_expiration_delta) do
73
+ response = ForestLiana::ForestApiRequester.get("/liana/v4/permissions/renderings/#{rendering_id}")
74
+ raise 'Unable to fetch scopes' unless response.is_a?(Net::HTTPOK)
75
+ parsed_response = JSON.parse(response.body)
76
+
77
+ {
78
+ 'scopes' => decode_scope(parsed_response['collections']),
79
+ 'team' => parsed_response['team']
80
+ }
81
+ end
79
82
  end
80
83
  end
81
84
 
@@ -20,8 +20,7 @@ module ForestLiana
20
20
 
21
21
  def perform(resource)
22
22
  @resource = @records = resource
23
- @tables_associated_to_relations_name =
24
- ForestLiana::QueryHelper.get_tables_associated_to_relations_name(@resource)
23
+ @tables_associated_to_relations_name = nil
25
24
  # Two different contributors, kept apart rather than folded into one flag: a lambda that
26
25
  # runs without raising isn't necessarily a lambda that filtered anything (one returning its
27
26
  # query untouched pushes no condition, constrains nothing). Conflating the two let a
@@ -265,12 +264,16 @@ module ForestLiana
265
264
  end
266
265
 
267
266
  def association_table_name(name)
268
- QueryHelper.get_tables_associated_to_relations_name(@records).detect { |key, values|
267
+ tables_associated_to_relations_name.detect { |key, values|
269
268
  break key if Array(values).include?(name)
270
269
  }
271
270
 
272
271
  end
273
272
 
273
+ def tables_associated_to_relations_name
274
+ @tables_associated_to_relations_name ||= QueryHelper.get_tables_associated_to_relations_name(@resource)
275
+ end
276
+
274
277
  # Recorded here, checked separately by +assert_sort_readable!+: a count route runs this same
275
278
  # parsing (Rails strips the ORDER BY from the emitted COUNT SQL on its own) but must not refuse
276
279
  # a sort it never actually applies, so the two are split rather than checked inline.
@@ -0,0 +1,11 @@
1
+ module ForestLiana
2
+ module SingleRecordSelection
3
+ MESSAGE = 'This action can only be triggered on one record at a time.'
4
+
5
+ def self.allowed?(action, attributes)
6
+ return true unless action&.type == 'single'
7
+
8
+ !attributes[:all_records] && Array(attributes[:ids]).length == 1
9
+ end
10
+ end
11
+ end
@@ -27,7 +27,7 @@ module ForestLiana
27
27
  while (match = regex.match(value_with_context_variables_injected))
28
28
  context_variable_key = match[1]
29
29
 
30
- value_with_context_variables_injected.gsub!(
30
+ value_with_context_variables_injected = value_with_context_variables_injected.gsub(
31
31
  /{{#{context_variable_key}}}/,
32
32
  yield(context_variable_key)
33
33
  )
@@ -1,14 +1,27 @@
1
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.
2
+ # Remembers lookups over ForestLiana.models, .apimap and .names_overriden, and what
3
+ # .included_models/.excluded_models let through: on a large schema each one is a linear scan,
4
+ # schema_for_resource a scan of scans, and every request runs several. Any of these being
5
+ # replaced or growing (the bootstrapper, a Forest collection file, a code reload) drops everything
6
+ # remembered, so an answer never outlives what it read.
6
7
  module LookupCache
8
+ # Swapped as a whole, so a thread never pairs new sources with the stores of old ones.
9
+ Snapshot = Struct.new(:sources, :sizes, :stores) do
10
+ def current?(models, apimap, names, included, excluded)
11
+ same?(0, models) && same?(1, apimap) && same?(2, names) && same?(3, included) && same?(4, excluded)
12
+ end
13
+
14
+ private
15
+
16
+ def same?(index, source)
17
+ source.equal?(sources[index]) && source.size == sizes[index]
18
+ end
19
+ end
20
+
7
21
  # A miss is not remembered: the router resolves a request's collection before authenticating
8
22
  # it, so caching unknown names would let any caller grow this cache without bound.
9
23
  def self.fetch(namespace, key)
10
- reset_if_stale
11
- store = (@stores[namespace] ||= {})
24
+ store = (current_stores[namespace] ||= {})
12
25
  return store[key] if store.key?(key)
13
26
 
14
27
  value = yield
@@ -17,21 +30,23 @@ module ForestLiana
17
30
  end
18
31
 
19
32
  def self.reset!
20
- @stores = nil
33
+ @snapshot = nil
21
34
  end
22
35
 
23
- def self.reset_if_stale
36
+ # Runs on every lookup, thousands of times per list: a hit must not allocate.
37
+ def self.current_stores
24
38
  models = ForestLiana.models
25
39
  apimap = ForestLiana.apimap
26
40
  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
41
+ included = ForestLiana.included_models
42
+ excluded = ForestLiana.excluded_models
43
+ snapshot = @snapshot
44
+ return snapshot.stores if snapshot&.current?(models, apimap, names, included, excluded)
30
45
 
31
- @models, @models_size = models, models.size
32
- @apimap, @apimap_size = apimap, apimap.size
33
- @names, @names_size = names, names.size
34
- @stores = {}
46
+ sources = [models, apimap, names, included, excluded]
47
+ snapshot = Snapshot.new(sources, sources.map(&:size), {})
48
+ @snapshot = snapshot
49
+ snapshot.stores
35
50
  end
36
51
  end
37
52
  end
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.22.11"
2
+ VERSION = "9.22.13"
3
3
  end
@@ -76,16 +76,22 @@ module ForestLiana
76
76
  end
77
77
  end
78
78
 
79
- context 'when regex matching raises an exception' do
79
+ context 'when search_value is not a valid regex' do
80
80
  let(:search_value) { '[invalid_regex' }
81
81
 
82
- it 'handles the exception gracefully' do
82
+ it 'does not match a value that does not contain it' do
83
83
  expect {
84
84
  described_class.detect_match_and_decorate(record, index, field_name, value, search_value, match_fields)
85
85
  }.not_to raise_error
86
86
 
87
87
  expect(match_fields).to be_empty
88
88
  end
89
+
90
+ it 'matches a value that contains it literally' do
91
+ described_class.detect_match_and_decorate(record, index, field_name, 'a[invalid_regex', search_value, match_fields)
92
+
93
+ expect(match_fields[index][:search]).to eq(['name'])
94
+ end
89
95
  end
90
96
 
91
97
  context 'with special regex characters in search_value' do
@@ -97,6 +103,12 @@ module ForestLiana
97
103
 
98
104
  expect(match_fields[index][:search]).to include('name')
99
105
  end
106
+
107
+ it 'does not match a value without the literal character' do
108
+ described_class.detect_match_and_decorate(record, index, field_name, 'testXemail', search_value, match_fields)
109
+
110
+ expect(match_fields).to be_empty
111
+ end
100
112
  end
101
113
  end
102
114
 
@@ -179,6 +191,31 @@ module ForestLiana
179
191
 
180
192
  expect(result[0][:search]).to eq(['name'])
181
193
  end
194
+
195
+ it 'compiles the search pattern once for every record and field' do
196
+ allow(Regexp).to receive(:new).and_call_original
197
+ expect(Regexp).to receive(:new).with('john', Regexp::IGNORECASE).once.and_call_original
198
+
199
+ described_class.decorate_for_search(records_serialized, field_names, search_value)
200
+ end
201
+
202
+ it 'highlights a search holding regex metacharacters literally' do
203
+ records_serialized['data'][1]['attributes']['name'] = 'f(x)'
204
+
205
+ result = described_class.decorate_for_search(records_serialized, field_names, '(')
206
+
207
+ expect(result).to eq({ 1 => { id: '2', search: ['name'] } })
208
+ end
209
+
210
+ it 'does not read a quantifier as one' do
211
+ records_serialized['data'][1]['attributes']['name'] = 'aaa'
212
+
213
+ expect(described_class.decorate_for_search(records_serialized, field_names, 'a*')).to be_nil
214
+ end
215
+
216
+ it 'does not read an alternation as one' do
217
+ expect(described_class.decorate_for_search(records_serialized, field_names, 'zzz|john')).to be_nil
218
+ end
182
219
  end
183
220
 
184
221
  context 'when no matches are found' do
@@ -228,6 +265,12 @@ module ForestLiana
228
265
  described_class.decorate_for_search(records_serialized, field_names, search_value)
229
266
  }.to raise_error(ArgumentError, "Missing 'attributes' key in record #{records_serialized['data'][0]}")
230
267
  end
268
+
269
+ it 'raises ArgumentError even when the search holds regex metacharacters' do
270
+ expect {
271
+ described_class.decorate_for_search(records_serialized, field_names, '[john')
272
+ }.to raise_error(ArgumentError, "Missing 'attributes' key in record #{records_serialized['data'][0]}")
273
+ end
231
274
  end
232
275
  end
233
276
  end