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.
- checksums.yaml +4 -4
- data/app/controllers/forest_liana/actions_controller.rb +10 -0
- data/app/controllers/forest_liana/application_controller.rb +13 -2
- data/app/helpers/forest_liana/decoration_helper.rb +9 -3
- data/app/helpers/forest_liana/query_helper.rb +12 -9
- data/app/serializers/forest_liana/serializer_factory.rb +12 -3
- data/app/services/forest_liana/ability/permission.rb +50 -14
- data/app/services/forest_liana/base_getter.rb +1 -2
- data/app/services/forest_liana/request_cache.rb +28 -0
- data/app/services/forest_liana/resources_getter.rb +1 -1
- data/app/services/forest_liana/schema_utils.rb +13 -7
- data/app/services/forest_liana/scope_manager.rb +12 -9
- data/app/services/forest_liana/search_query_builder.rb +6 -3
- data/app/services/forest_liana/single_record_selection.rb +11 -0
- data/app/services/forest_liana/utils/context_variables_injector.rb +1 -1
- data/lib/forest_liana/lookup_cache.rb +30 -15
- data/lib/forest_liana/version.rb +1 -1
- data/spec/helpers/forest_liana/decoration_helper_spec.rb +45 -2
- data/spec/lib/forest_liana/lookup_cache_spec.rb +113 -0
- data/spec/requests/actions_controller_spec.rb +79 -0
- data/spec/requests/permissions_cache_footprint_spec.rb +122 -0
- data/spec/requests/schema_lookup_footprint_spec.rb +62 -0
- data/spec/services/forest_liana/ability/permission_spec.rb +74 -0
- data/spec/services/forest_liana/request_cache_spec.rb +50 -0
- data/spec/services/forest_liana/resources_getter_spec.rb +17 -0
- data/spec/services/forest_liana/scope_manager_spec.rb +16 -1
- data/spec/services/forest_liana/search_query_builder_spec.rb +30 -0
- data/spec/services/forest_liana/serializer_factory_spec.rb +15 -0
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fcbf97d59c4b454c53e5f068f969e8e503e2a722c1a011e2d03b7f9e73a0c152
|
|
4
|
+
data.tar.gz: cd6608d3c224d6daaedd240928a34cec77d49c35bfdf52fdb0d5b5187a23cf02
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
263
|
-
|
|
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(
|
|
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,
|
|
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'],
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 &&=
|
|
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
|
|
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 =
|
|
255
|
-
users
|
|
256
|
-
|
|
257
|
-
users
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
(
|
|
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
|
-
.
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
3
|
-
# schema each one is a linear scan,
|
|
4
|
-
#
|
|
5
|
-
# file, a code reload) drops everything
|
|
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
|
-
|
|
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
|
-
@
|
|
33
|
+
@snapshot = nil
|
|
21
34
|
end
|
|
22
35
|
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
@
|
|
34
|
-
|
|
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
|
data/lib/forest_liana/version.rb
CHANGED
|
@@ -76,16 +76,22 @@ module ForestLiana
|
|
|
76
76
|
end
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
-
context 'when
|
|
79
|
+
context 'when search_value is not a valid regex' do
|
|
80
80
|
let(:search_value) { '[invalid_regex' }
|
|
81
81
|
|
|
82
|
-
it '
|
|
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
|