forest_liana 9.22.11 → 9.22.12
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/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 +31 -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/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 +38 -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/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 +29 -0
- data/spec/services/forest_liana/serializer_factory_spec.rb +15 -0
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e9584149ffb7d20be9b8f4dacd36f5bf1d95c5276b1b34f35d86e750e0f696f2
|
|
4
|
+
data.tar.gz: 2d24e798d72ec62aabec3ebe9fdb070f857c8e511b02d4bbe8e8ce1880e03032
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 87d06d138209e8ca03019c971e0aa24e0c55bb8f71f03df225cf8de312f2fcb7dbac04cd398308c8a2768f7226b9c36e06ccdbed6d63225a5abe5a0bc26cc448
|
|
7
|
+
data.tar.gz: 31a54daccf9d158680b28d5747f35a467943f94a779448f812f43dfe8f84ba9cd921297dc930a958d7b6bd1660f8d3becd15abb50480db9c516d8b7a21e98508
|
|
@@ -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)
|
|
@@ -54,8 +54,8 @@ module ForestLiana
|
|
|
54
54
|
|
|
55
55
|
# +refetch+ drops the cached permissions first. Only a caller about to refuse on a denial asks
|
|
56
56
|
# 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.
|
|
57
|
+
# refetch rewrites the cluster-wide `forest.collections` entry and this request's copy, so a
|
|
58
|
+
# field that is merely redacted is answered from the cache.
|
|
59
59
|
def read_permissions(user, collection_names, refetch: false)
|
|
60
60
|
@read_permissions_cache ||= {}
|
|
61
61
|
to_fetch = refetch ? collection_names.uniq : collection_names.uniq - @read_permissions_cache.keys
|
|
@@ -251,17 +251,20 @@ module ForestLiana
|
|
|
251
251
|
end
|
|
252
252
|
|
|
253
253
|
def get_user_data(user_id, force_fetch = true)
|
|
254
|
-
cache =
|
|
255
|
-
users
|
|
256
|
-
|
|
257
|
-
users
|
|
258
|
-
|
|
254
|
+
cache = RequestCache.fetch('forest.users') do
|
|
255
|
+
Rails.cache.fetch('forest.users', expires_in: TTL) do
|
|
256
|
+
users = {}
|
|
257
|
+
get_permissions('/liana/v4/permissions/users').each do |user|
|
|
258
|
+
users[user['id'].to_s] = user
|
|
259
|
+
end
|
|
259
260
|
|
|
260
|
-
|
|
261
|
+
users
|
|
262
|
+
end
|
|
261
263
|
end
|
|
262
264
|
|
|
263
265
|
if !cache.key?(user_id.to_s) && force_fetch
|
|
264
266
|
Rails.cache.delete('forest.users')
|
|
267
|
+
RequestCache.delete('forest.users')
|
|
265
268
|
get_user_data(user_id, false)
|
|
266
269
|
else
|
|
267
270
|
cache[user_id.to_s]
|
|
@@ -273,9 +276,15 @@ module ForestLiana
|
|
|
273
276
|
# followed failed, so during a Forest API incident every pod re-hit the dead API on every
|
|
274
277
|
# request instead of riding on the last good payload until the TTL ran out.
|
|
275
278
|
def get_collections_permissions_data(force_fetch = false)
|
|
276
|
-
|
|
279
|
+
if force_fetch == true
|
|
280
|
+
collections = fetch_collections_permissions
|
|
281
|
+
Rails.cache.write('forest.collections', collections, expires_in: TTL)
|
|
282
|
+
return RequestCache.write('forest.collections', collections)
|
|
283
|
+
end
|
|
277
284
|
|
|
278
|
-
|
|
285
|
+
RequestCache.fetch('forest.collections') do
|
|
286
|
+
Rails.cache.fetch('forest.collections', expires_in: TTL) { fetch_collections_permissions }
|
|
287
|
+
end
|
|
279
288
|
end
|
|
280
289
|
|
|
281
290
|
def fetch_collections_permissions
|
|
@@ -285,9 +294,15 @@ module ForestLiana
|
|
|
285
294
|
end
|
|
286
295
|
|
|
287
296
|
def get_chart_data(rendering_id, force_fetch = false)
|
|
288
|
-
|
|
297
|
+
if force_fetch == true
|
|
298
|
+
stats = fetch_chart_data(rendering_id)
|
|
299
|
+
Rails.cache.write('forest.stats', stats, expires_in: TTL)
|
|
300
|
+
return RequestCache.write('forest.stats', stats)
|
|
301
|
+
end
|
|
289
302
|
|
|
290
|
-
|
|
303
|
+
RequestCache.fetch('forest.stats') do
|
|
304
|
+
Rails.cache.fetch('forest.stats', expires_in: TTL) { fetch_chart_data(rendering_id) }
|
|
305
|
+
end
|
|
291
306
|
end
|
|
292
307
|
|
|
293
308
|
def fetch_chart_data(rendering_id)
|
|
@@ -297,8 +312,10 @@ module ForestLiana
|
|
|
297
312
|
end
|
|
298
313
|
|
|
299
314
|
def has_permission_system?
|
|
300
|
-
|
|
301
|
-
(
|
|
315
|
+
RequestCache.fetch('forest.has_permission') do
|
|
316
|
+
Rails.cache.fetch('forest.has_permission') do
|
|
317
|
+
(get_permissions('/liana/v4/permissions/environment') == true) ? false : true
|
|
318
|
+
end
|
|
302
319
|
end
|
|
303
320
|
end
|
|
304
321
|
|
|
@@ -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.
|
|
@@ -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
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
require 'rails_helper'
|
|
2
2
|
|
|
3
3
|
describe 'Lookups by name over the schema' do
|
|
4
|
+
describe 'ForestLiana::LookupCache.fetch' do
|
|
5
|
+
it 'answers a remembered lookup without allocating' do
|
|
6
|
+
ForestLiana::LookupCache.fetch(:spec, :key) { :value }
|
|
7
|
+
|
|
8
|
+
allocated = GC.stat(:total_allocated_objects)
|
|
9
|
+
1_000.times { ForestLiana::LookupCache.fetch(:spec, :key) { :value } }
|
|
10
|
+
|
|
11
|
+
expect(GC.stat(:total_allocated_objects) - allocated).to be < 100
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
4
15
|
describe 'ForestLiana.name_for' do
|
|
5
16
|
after { ForestLiana.names_overriden.delete(Tree) }
|
|
6
17
|
|
|
@@ -49,6 +60,108 @@ describe 'Lookups by name over the schema' do
|
|
|
49
60
|
end
|
|
50
61
|
end
|
|
51
62
|
|
|
63
|
+
describe 'ForestLiana::SchemaUtils.polymorphic_models' do
|
|
64
|
+
let(:addressable) { Address.reflect_on_association(:addressable) }
|
|
65
|
+
|
|
66
|
+
it 'scans the models once for repeated lookups of a relation' do
|
|
67
|
+
expect(ForestLiana::SchemaUtils).to receive(:scan_polymorphic_models).with(:addressable).once.and_call_original
|
|
68
|
+
|
|
69
|
+
3.times { expect(ForestLiana::SchemaUtils.polymorphic_models(addressable)).to eq [User] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'returns a frozen list' do
|
|
73
|
+
expect(ForestLiana::SchemaUtils.polymorphic_models(addressable)).to be_frozen
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'remembers a relation no model targets' do
|
|
77
|
+
orphan = double(name: :orphan)
|
|
78
|
+
expect(ForestLiana::SchemaUtils).to receive(:scan_polymorphic_models).with(:orphan).once.and_call_original
|
|
79
|
+
|
|
80
|
+
2.times { expect(ForestLiana::SchemaUtils.polymorphic_models(orphan)).to eq [] }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'finds a target added to the models after a first lookup' do
|
|
84
|
+
target = Class.new(ActiveRecord::Base) do
|
|
85
|
+
self.table_name = 'trees'
|
|
86
|
+
has_many :addresses, as: :addressable
|
|
87
|
+
end
|
|
88
|
+
expect(ForestLiana::SchemaUtils.polymorphic_models(addressable)).to eq [User]
|
|
89
|
+
|
|
90
|
+
ForestLiana.models << target
|
|
91
|
+
|
|
92
|
+
expect(ForestLiana::SchemaUtils.polymorphic_models(addressable)).to contain_exactly(User, target)
|
|
93
|
+
ensure
|
|
94
|
+
ForestLiana.models.delete(target)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
describe 'ForestLiana::SchemaUtils.associations' do
|
|
99
|
+
it 'reflects on a model once for repeated lookups, a relation included' do
|
|
100
|
+
expect(ForestLiana::SchemaUtils).to receive(:scan_associations).with(Tree).once.and_call_original
|
|
101
|
+
|
|
102
|
+
expected = Tree.reflect_on_all_associations.map(&:name)
|
|
103
|
+
expect(ForestLiana::SchemaUtils.associations(Tree).map(&:name)).to match_array(expected)
|
|
104
|
+
expect(ForestLiana::SchemaUtils.associations(Tree.where(name: 'oak')).map(&:name)).to match_array(expected)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'keeps only the to-many associations for many_associations' do
|
|
108
|
+
expect(ForestLiana::SchemaUtils.many_associations(Tree).map(&:name)).to eq([:owner_named_trees])
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
describe 'ForestLiana::QueryHelper.get_one_associations' do
|
|
113
|
+
it 'computes the to-one associations of a model once for repeated lookups' do
|
|
114
|
+
expect(ForestLiana::QueryHelper).to receive(:scan_one_associations).with(Address).once.and_call_original
|
|
115
|
+
|
|
116
|
+
3.times { expect(ForestLiana::QueryHelper.get_one_associations(Address).map(&:name)).to eq [:addressable] }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'returns a frozen list' do
|
|
120
|
+
expect(ForestLiana::QueryHelper.get_one_associations(Address)).to be_frozen
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it 'drops a target excluded after a first lookup' do
|
|
124
|
+
previous = ForestLiana.excluded_models
|
|
125
|
+
expect(ForestLiana::QueryHelper.get_one_associations(Address).map(&:name)).to eq [:addressable]
|
|
126
|
+
|
|
127
|
+
ForestLiana.excluded_models = previous + ['User']
|
|
128
|
+
|
|
129
|
+
expect(ForestLiana::QueryHelper.get_one_associations(Address)).to eq []
|
|
130
|
+
ensure
|
|
131
|
+
ForestLiana.excluded_models = previous
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'drops a target excluded by a replacement list of the same size' do
|
|
135
|
+
previous = ForestLiana.excluded_models
|
|
136
|
+
ForestLiana.excluded_models = previous + ['Tree']
|
|
137
|
+
expect(ForestLiana::QueryHelper.get_one_associations(Address).map(&:name)).to eq [:addressable]
|
|
138
|
+
|
|
139
|
+
ForestLiana.excluded_models = previous + ['User']
|
|
140
|
+
|
|
141
|
+
expect(ForestLiana::QueryHelper.get_one_associations(Address)).to eq []
|
|
142
|
+
ensure
|
|
143
|
+
ForestLiana.excluded_models = previous
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it 'drops a target left out of the included models after a first lookup' do
|
|
147
|
+
previous = ForestLiana.included_models
|
|
148
|
+
expect(ForestLiana::QueryHelper.get_one_associations(Address).map(&:name)).to eq [:addressable]
|
|
149
|
+
|
|
150
|
+
ForestLiana.included_models = ['Address']
|
|
151
|
+
|
|
152
|
+
expect(ForestLiana::QueryHelper.get_one_associations(Address)).to eq []
|
|
153
|
+
ensure
|
|
154
|
+
ForestLiana.included_models = previous
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it 'answers a relation from the lookup of its model' do
|
|
158
|
+
expect(ForestLiana::QueryHelper).to receive(:scan_one_associations).with(Address).once.and_call_original
|
|
159
|
+
|
|
160
|
+
expect(ForestLiana::QueryHelper.get_one_associations(Address).map(&:name)).to eq [:addressable]
|
|
161
|
+
expect(ForestLiana::QueryHelper.get_one_associations(Address.where(city: 'Papeete')).map(&:name)).to eq [:addressable]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
52
165
|
describe 'ForestLiana.schema_for_resource' do
|
|
53
166
|
it 'resolves the collection of a resource once for repeated lookups' do
|
|
54
167
|
expect(ForestLiana.schema_for_resource(Tree).name).to eq 'Tree'
|
|
@@ -764,5 +764,43 @@ describe 'Requesting Actions routes', :type => :request do
|
|
|
764
764
|
expect(result).not_to have_key('Island')
|
|
765
765
|
end
|
|
766
766
|
end
|
|
767
|
+
|
|
768
|
+
context 'with several requested relations' do
|
|
769
|
+
let(:smart_relations) do
|
|
770
|
+
[
|
|
771
|
+
{ field: :organization, reference: 'Api__OrganizationsView.id', is_virtual: true, is_searchable: false, type: 'String' },
|
|
772
|
+
{ field: :parent_island, reference: 'Island.id', is_virtual: true, is_searchable: false, type: 'String' }
|
|
773
|
+
]
|
|
774
|
+
end
|
|
775
|
+
let(:forest_collection) { double('forest_collection', name: collection_name, fields_smart_belongs_to: smart_relations) }
|
|
776
|
+
let(:params_fields) do
|
|
777
|
+
ActionController::Parameters.new({ 'Island' => 'id,name', 'organization' => 'name', 'parent_island' => 'name' })
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
before do
|
|
781
|
+
allow(ForestLiana).to receive(:apimap).and_return([forest_collection])
|
|
782
|
+
allow(controller).to receive(:params).and_return(
|
|
783
|
+
ActionController::Parameters.new({ collection: 'Island', fields: params_fields.to_unsafe_h })
|
|
784
|
+
)
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
it 'finds the collection once' do
|
|
788
|
+
expect(forest_collection).to receive(:name).once.and_return(collection_name)
|
|
789
|
+
|
|
790
|
+
controller.send(:fields_per_model, params_fields, model)
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
it 'reads its smart belongs-to once' do
|
|
794
|
+
expect(forest_collection).to receive(:fields_smart_belongs_to).once.and_return(smart_relations)
|
|
795
|
+
|
|
796
|
+
controller.send(:fields_per_model, params_fields, model)
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
it 'keys every relation as it would alone' do
|
|
800
|
+
result = controller.send(:fields_per_model, params_fields, model)
|
|
801
|
+
|
|
802
|
+
expect(result).to eq('Island' => 'id,name', 'Api__OrganizationsView' => 'name', 'parent_island' => 'name')
|
|
803
|
+
end
|
|
804
|
+
end
|
|
767
805
|
end
|
|
768
806
|
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require 'rails_helper'
|
|
2
|
+
|
|
3
|
+
describe 'Permission and scope reads of a request', type: :request do
|
|
4
|
+
token = JWT.encode({ id: 1, email: 'michael.kelso@that70.show', first_name: 'Michael', last_name: 'Kelso',
|
|
5
|
+
team: 'Operations', rendering_id: 16, exp: Time.now.to_i + 2.weeks.to_i,
|
|
6
|
+
permission_level: 'user' },
|
|
7
|
+
ForestLiana.auth_secret, 'HS256')
|
|
8
|
+
headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json',
|
|
9
|
+
'Authorization' => "Bearer #{token}" }
|
|
10
|
+
|
|
11
|
+
let(:auth_headers) { headers }
|
|
12
|
+
let(:readable) { %w[Tree Location User Island] }
|
|
13
|
+
let(:refetched_readable) { readable }
|
|
14
|
+
let(:scope) { { 'field' => 'name', 'operator' => 'present', 'value' => nil } }
|
|
15
|
+
let(:users) { [{ 'id' => 1, 'roleId' => 1 }] }
|
|
16
|
+
let(:refetched_users) { users }
|
|
17
|
+
let(:cache) { ActiveSupport::Cache::MemoryStore.new }
|
|
18
|
+
let!(:tree) do
|
|
19
|
+
user = User.create!(name: 'Michel')
|
|
20
|
+
tree = Tree.create!(name: 'Lemon Tree', owner: user, cutter: user)
|
|
21
|
+
Location.create!(coordinates: '1,2', island: Island.create!(name: 'Lemon Island', trees: [tree]))
|
|
22
|
+
tree
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def environment(readable_collections)
|
|
26
|
+
enabled = { 'roles' => [1] }
|
|
27
|
+
collections = %w[Tree Location User Island].to_h do |name|
|
|
28
|
+
read = readable_collections.include?(name) ? enabled : { 'roles' => [] }
|
|
29
|
+
crud = %w[browseEnabled editEnabled addEnabled deleteEnabled exportEnabled].to_h { |key| [key, enabled] }
|
|
30
|
+
[name, { 'collection' => crud.merge('readEnabled' => read), 'actions' => {} }]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
{ 'collections' => collections }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
before do
|
|
37
|
+
allow(Rails).to receive(:cache).and_return(cache)
|
|
38
|
+
@environment_fetches = 0
|
|
39
|
+
@users_fetches = 0
|
|
40
|
+
allow_any_instance_of(ForestLiana::Ability::Fetch).to receive(:get_permissions) do |_, path|
|
|
41
|
+
case path
|
|
42
|
+
when '/liana/v4/permissions/environment'
|
|
43
|
+
@environment_fetches += 1
|
|
44
|
+
environment(@environment_fetches == 1 ? readable : refetched_readable)
|
|
45
|
+
when '/liana/v4/permissions/users'
|
|
46
|
+
@users_fetches += 1
|
|
47
|
+
@users_fetches == 1 ? users : refetched_users
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
rendering = Net::HTTPOK.new('1.1', 200, 'OK')
|
|
51
|
+
allow(rendering).to receive(:body).and_return(JSON.generate(
|
|
52
|
+
'collections' => { 'Tree' => { 'scope' => scope } }, 'team' => { 'id' => 1, 'name' => 'Operations' }
|
|
53
|
+
))
|
|
54
|
+
allow(ForestLiana::ForestApiRequester).to receive(:get).and_return(rendering)
|
|
55
|
+
allow(ForestLiana::IpWhitelist).to receive(:retrieve) { true }
|
|
56
|
+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_whitelist_retrieved) { true }
|
|
57
|
+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_valid) { true }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
after do
|
|
61
|
+
[Location, Tree, Island, User].each(&:destroy_all)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def cache_reads
|
|
65
|
+
reads = Hash.new(0)
|
|
66
|
+
subscriber = ActiveSupport::Notifications.subscribe('cache_read.active_support') do |*, payload|
|
|
67
|
+
reads[payload[:key].to_s] += 1
|
|
68
|
+
end
|
|
69
|
+
yield
|
|
70
|
+
reads
|
|
71
|
+
ensure
|
|
72
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
let(:fields) { { 'Tree' => 'id,name,owner', 'owner' => 'name' } }
|
|
76
|
+
let(:filtered) do
|
|
77
|
+
{ fields: fields, filters: JSON.generate(field: 'owner:name', operator: 'present', value: nil),
|
|
78
|
+
sort: 'owner.name', timezone: 'Europe/Paris' }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
'a list filtered and sorted on a relation' => ->(tree, params) { ['/forest/Tree', params.merge(page: { number: 1, size: 10 })] },
|
|
83
|
+
'its count' => ->(tree, params) { ['/forest/Tree/count', params.except(:sort)] },
|
|
84
|
+
'a get-one' => ->(tree, params) { ["/forest/Tree/#{tree.id}", { timezone: 'Europe/Paris' }] },
|
|
85
|
+
'a related list' => ->(tree, _) { ["/forest/Island/#{tree.island_id}/relationships/trees", { fields: { 'Tree' => 'id,name,owner', 'owner' => 'name' }, timezone: 'Europe/Paris' }] },
|
|
86
|
+
}.each do |name, request|
|
|
87
|
+
it "reads each key at most once for #{name}" do
|
|
88
|
+
path, params = request.call(tree, filtered)
|
|
89
|
+
reads = cache_reads { get path, params: params, headers: auth_headers }
|
|
90
|
+
|
|
91
|
+
expect(response).to have_http_status(200)
|
|
92
|
+
expect(reads.keys).to include('forest.collections', 'forest.users')
|
|
93
|
+
expect(reads.values).to all(eq(1))
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
describe 'when a relation is denied by a stale cache' do
|
|
98
|
+
let(:readable) { %w[Tree Location Island] }
|
|
99
|
+
let(:refetched_readable) { %w[Tree Location User Island] }
|
|
100
|
+
|
|
101
|
+
it 'refetches once and answers the rest of the request from the fresh permissions' do
|
|
102
|
+
cache.write('forest.has_permission', true)
|
|
103
|
+
get '/forest/Tree', params: filtered.merge(page: { number: 1, size: 10 }), headers: auth_headers
|
|
104
|
+
|
|
105
|
+
expect(response).to have_http_status(200)
|
|
106
|
+
expect(JSON.parse(response.body)['included'].map { |record| record['type'] }).to include('User')
|
|
107
|
+
expect(@environment_fetches).to eq(2)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
describe 'when the user is missing from a stale users cache' do
|
|
112
|
+
let(:users) { [] }
|
|
113
|
+
let(:refetched_users) { [{ 'id' => 1, 'roleId' => 1 }] }
|
|
114
|
+
|
|
115
|
+
it 'refetches the users once and authorizes the request' do
|
|
116
|
+
get '/forest/Tree', params: filtered.merge(page: { number: 1, size: 10 }), headers: auth_headers
|
|
117
|
+
|
|
118
|
+
expect(response).to have_http_status(200)
|
|
119
|
+
expect(@users_fetches).to eq(2)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require 'rails_helper'
|
|
2
|
+
|
|
3
|
+
describe 'Schema scans of a list call', type: :request do
|
|
4
|
+
token = JWT.encode({ id: 38, email: 'michael.kelso@that70.show', first_name: 'Michael',
|
|
5
|
+
last_name: 'Kelso', team: 'Operations', rendering_id: 16,
|
|
6
|
+
exp: Time.now.to_i + 2.weeks.to_i, permission_level: 'admin' },
|
|
7
|
+
ForestLiana.auth_secret, 'HS256')
|
|
8
|
+
headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json',
|
|
9
|
+
'Authorization' => "Bearer #{token}" }
|
|
10
|
+
|
|
11
|
+
let(:auth_headers) { headers }
|
|
12
|
+
let!(:user) { User.create!(name: 'Michel') }
|
|
13
|
+
let!(:address) { Address.create!(line1: '1 Palm Street', city: 'Papeete', zipcode: '98713', addressable: user) }
|
|
14
|
+
|
|
15
|
+
before do
|
|
16
|
+
allow(ForestLiana::IpWhitelist).to receive(:retrieve) { true }
|
|
17
|
+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_whitelist_retrieved) { true }
|
|
18
|
+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_valid) { true }
|
|
19
|
+
allow_any_instance_of(ForestLiana::Ability).to receive(:forest_authorize!) { true }
|
|
20
|
+
Rails.cache.write('forest.has_permission', false)
|
|
21
|
+
allow(ForestLiana::ScopeManager).to receive(:fetch_scopes)
|
|
22
|
+
.and_return({ 'scopes' => {}, 'team' => { 'id' => '1', 'name' => 'Operations' } })
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
after do
|
|
26
|
+
Address.destroy_all
|
|
27
|
+
User.destroy_all
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def list(fields)
|
|
31
|
+
get '/forest/Address', params: { fields: fields, page: { 'number' => '1', 'size' => '10' },
|
|
32
|
+
sort: '-id', timezone: 'Europe/Paris' }, headers: auth_headers
|
|
33
|
+
expect(response).to have_http_status(200)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'scans the models for the targets of a polymorphic relation once, whatever the projection' do
|
|
37
|
+
scans = 0
|
|
38
|
+
allow(ForestLiana::SchemaUtils).to receive(:scan_polymorphic_models).and_wrap_original do |original, *args|
|
|
39
|
+
scans += 1
|
|
40
|
+
original.call(*args)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
list('Address' => 'id,line1')
|
|
44
|
+
list('Address' => 'id,line1,city,zipcode,addressable', 'addressable' => 'id')
|
|
45
|
+
|
|
46
|
+
expect(scans).to eq(1)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'reflects on the serialized model once, whatever the number of rows' do
|
|
50
|
+
2.times { Address.create!(line1: 'Another street', city: 'Papeete', zipcode: '98713', addressable: user) }
|
|
51
|
+
scans = []
|
|
52
|
+
allow(ForestLiana::SchemaUtils).to receive(:scan_associations).and_wrap_original do |original, model|
|
|
53
|
+
scans << model
|
|
54
|
+
original.call(model)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
list('Address' => 'id,line1,addressable', 'addressable' => 'id')
|
|
58
|
+
|
|
59
|
+
expect(JSON.parse(response.body)['data'].size).to eq(3)
|
|
60
|
+
expect(scans.count(Address)).to eq(1)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module ForestLiana
|
|
2
|
+
describe RequestCache do
|
|
3
|
+
it 'runs the block on every call outside a request' do
|
|
4
|
+
calls = 0
|
|
5
|
+
2.times { described_class.fetch('key') { calls += 1 } }
|
|
6
|
+
|
|
7
|
+
expect(calls).to eq(2)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'runs the block once per key within a request' do
|
|
11
|
+
calls = 0
|
|
12
|
+
values = described_class.with { Array.new(2) { described_class.fetch('key') { calls += 1 } } }
|
|
13
|
+
|
|
14
|
+
expect(calls).to eq(1)
|
|
15
|
+
expect(values).to eq([1, 1])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'remembers a false value' do
|
|
19
|
+
calls = 0
|
|
20
|
+
described_class.with { 2.times { described_class.fetch('key') { calls += 1; false } } }
|
|
21
|
+
|
|
22
|
+
expect(calls).to eq(1)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'answers a written value to the rest of the request' do
|
|
26
|
+
described_class.with do
|
|
27
|
+
described_class.fetch('key') { 'stale' }
|
|
28
|
+
described_class.write('key', 'fresh')
|
|
29
|
+
|
|
30
|
+
expect(described_class.fetch('key') { 'unused' }).to eq('fresh')
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'runs the block again after a delete' do
|
|
35
|
+
described_class.with do
|
|
36
|
+
described_class.fetch('key') { 'stale' }
|
|
37
|
+
described_class.delete('key')
|
|
38
|
+
|
|
39
|
+
expect(described_class.fetch('key') { 'fresh' }).to eq('fresh')
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'forgets everything once the request is over' do
|
|
44
|
+
described_class.with { described_class.fetch('key') { 'first' } }
|
|
45
|
+
|
|
46
|
+
expect(described_class.with { described_class.fetch('key') { 'second' } }).to eq('second')
|
|
47
|
+
expect(described_class.store).to be_nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -151,6 +151,23 @@ module ForestLiana
|
|
|
151
151
|
end
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
+
describe 'when a live query segment uses a context variable' do
|
|
155
|
+
let(:user) { { 'id' => '1', 'name' => 'Michel', 'rendering_id' => rendering_id } }
|
|
156
|
+
let(:getter) do
|
|
157
|
+
described_class.new(resource, {
|
|
158
|
+
page: { size: pageSize, number: pageNumber },
|
|
159
|
+
fields: { 'User' => 'id,name' },
|
|
160
|
+
segmentQuery: "SELECT id FROM users WHERE name = '{{currentUser.name}}'",
|
|
161
|
+
}, user)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it 'injects the variable into the query it runs' do
|
|
165
|
+
getter.perform
|
|
166
|
+
|
|
167
|
+
expect(getter.records.map(&:name)).to eq(['Michel'])
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
154
171
|
describe 'when there are more records than the page size' do
|
|
155
172
|
describe 'when asking for the 1st page and 15 records' do
|
|
156
173
|
let(:pageSize) { 15 }
|
|
@@ -124,6 +124,16 @@ module ForestLiana
|
|
|
124
124
|
}
|
|
125
125
|
)
|
|
126
126
|
end
|
|
127
|
+
|
|
128
|
+
it 'injects each user their own values from a shared scope' do
|
|
129
|
+
other_user = user.merge('email' => 'other@forestadmin.com')
|
|
130
|
+
|
|
131
|
+
ForestLiana::RequestCache.with do
|
|
132
|
+
expect(described_class.get_scope(collection_name, user)['conditions'][0]['value']).to eq('jd@forestadmin.com')
|
|
133
|
+
expect(described_class.get_scope(collection_name, other_user)['conditions'][0]['value']).to eq('other@forestadmin.com')
|
|
134
|
+
expect(described_class.fetch_scopes(rendering_id)['scopes']['User']['conditions'][0]['value']).to eq('{{currentUser.email}}')
|
|
135
|
+
end
|
|
136
|
+
end
|
|
127
137
|
end
|
|
128
138
|
|
|
129
139
|
describe 'when target collection has no scopes' do
|
|
@@ -347,9 +357,14 @@ module ForestLiana
|
|
|
347
357
|
end
|
|
348
358
|
|
|
349
359
|
it 'should inject the context variables in the query' do
|
|
360
|
+
expect(described_class.inject_context_variables_on_query(query, user))
|
|
361
|
+
.to eq "SELECT id FROM users WHERE email = 'jd@forestadmin.com'"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
it 'should leave the query it was given untouched' do
|
|
350
365
|
described_class.inject_context_variables_on_query(query, user)
|
|
351
366
|
|
|
352
|
-
expect(query).to eq "SELECT id FROM users WHERE email = '
|
|
367
|
+
expect(query).to eq "SELECT id FROM users WHERE email = '{{currentUser.email}}'"
|
|
353
368
|
end
|
|
354
369
|
end
|
|
355
370
|
end
|
|
@@ -259,6 +259,35 @@ module ForestLiana
|
|
|
259
259
|
end
|
|
260
260
|
end
|
|
261
261
|
|
|
262
|
+
describe 'the tables behind a sort on a relation' do
|
|
263
|
+
let(:params) { { sort: sort } }
|
|
264
|
+
|
|
265
|
+
context 'without a sort on a relation' do
|
|
266
|
+
let(:sort) { '-name' }
|
|
267
|
+
|
|
268
|
+
it 'never resolves them' do
|
|
269
|
+
expect(ForestLiana::QueryHelper).not_to receive(:get_tables_associated_to_relations_name)
|
|
270
|
+
|
|
271
|
+
builder.perform(Tree)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
context 'with a sort on two relations' do
|
|
276
|
+
let(:sort) { '-owner.name,cutter.age' }
|
|
277
|
+
|
|
278
|
+
it 'resolves them once' do
|
|
279
|
+
expect(ForestLiana::QueryHelper).to receive(:get_tables_associated_to_relations_name)
|
|
280
|
+
.with(Tree).once.and_call_original
|
|
281
|
+
|
|
282
|
+
builder.perform(Tree)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
it 'orders on the tables of both relations' do
|
|
286
|
+
expect(builder.perform(Tree).to_sql).to include('ORDER BY "users"."name" DESC, "users"."age" ASC')
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
262
291
|
describe 'the footprint it reports' do
|
|
263
292
|
# search_param and the SQL it emits are meant to come from the same `push_condition` calls;
|
|
264
293
|
# this walks the emitted WHERE clause independently of the recording and compares the two as
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
module ForestLiana
|
|
2
2
|
describe SerializerFactory do
|
|
3
|
+
describe 'projected fields' do
|
|
4
|
+
it 'follows the fields of each serialize call, even when the calls share one context' do
|
|
5
|
+
tree = Tree.create!(name: 'Oak', owner: User.create!(name: 'Owner'))
|
|
6
|
+
serializer = described_class.new.serializer_for(Tree)
|
|
7
|
+
context = {}
|
|
8
|
+
attributes = lambda do |fields|
|
|
9
|
+
ForestAdmin::JSONAPI::Serializer.serialize(tree, serializer: serializer, context: context,
|
|
10
|
+
fields: { 'Tree' => fields })['data']['attributes']
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
expect(attributes.call('id,name')).to include('name' => 'Oak')
|
|
14
|
+
expect(attributes.call('id,age')).not_to have_key('name')
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
3
18
|
describe '#serializer_for has_one_relationships patch' do
|
|
4
19
|
let(:user) { User.create!(name: 'PatchTest') }
|
|
5
20
|
let(:island) { Island.create!(name: 'TestIsland') }
|
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.12
|
|
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-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -323,6 +323,7 @@ files:
|
|
|
323
323
|
- app/services/forest_liana/projection_parser.rb
|
|
324
324
|
- app/services/forest_liana/query_stat_getter.rb
|
|
325
325
|
- app/services/forest_liana/record_findable.rb
|
|
326
|
+
- app/services/forest_liana/request_cache.rb
|
|
326
327
|
- app/services/forest_liana/resource_creator.rb
|
|
327
328
|
- app/services/forest_liana/resource_getter.rb
|
|
328
329
|
- app/services/forest_liana/resource_updater.rb
|
|
@@ -484,11 +485,13 @@ files:
|
|
|
484
485
|
- spec/requests/cors_spec.rb
|
|
485
486
|
- spec/requests/count_spec.rb
|
|
486
487
|
- spec/requests/missing_attribute_valve_spec.rb
|
|
488
|
+
- spec/requests/permissions_cache_footprint_spec.rb
|
|
487
489
|
- spec/requests/projection_header_spec.rb
|
|
488
490
|
- spec/requests/projection_inherited_loads_spec.rb
|
|
489
491
|
- spec/requests/query_footprint_spec.rb
|
|
490
492
|
- spec/requests/related_list_nested_through_spec.rb
|
|
491
493
|
- spec/requests/resources_spec.rb
|
|
494
|
+
- spec/requests/schema_lookup_footprint_spec.rb
|
|
492
495
|
- spec/requests/smart_field_named_like_unexposed_relation_spec.rb
|
|
493
496
|
- spec/requests/stats_spec.rb
|
|
494
497
|
- spec/requests/sti_record_conversion_spec.rb
|
|
@@ -518,6 +521,7 @@ files:
|
|
|
518
521
|
- spec/services/forest_liana/line_stat_getter_spec.rb
|
|
519
522
|
- spec/services/forest_liana/pie_stat_getter_spec.rb
|
|
520
523
|
- spec/services/forest_liana/projection_parser_spec.rb
|
|
524
|
+
- spec/services/forest_liana/request_cache_spec.rb
|
|
521
525
|
- spec/services/forest_liana/resource_updater_spec.rb
|
|
522
526
|
- spec/services/forest_liana/resources_getter_composite_keys_spec.rb
|
|
523
527
|
- spec/services/forest_liana/resources_getter_spec.rb
|
|
@@ -843,11 +847,13 @@ test_files:
|
|
|
843
847
|
- spec/requests/cors_spec.rb
|
|
844
848
|
- spec/requests/count_spec.rb
|
|
845
849
|
- spec/requests/missing_attribute_valve_spec.rb
|
|
850
|
+
- spec/requests/permissions_cache_footprint_spec.rb
|
|
846
851
|
- spec/requests/projection_header_spec.rb
|
|
847
852
|
- spec/requests/projection_inherited_loads_spec.rb
|
|
848
853
|
- spec/requests/query_footprint_spec.rb
|
|
849
854
|
- spec/requests/related_list_nested_through_spec.rb
|
|
850
855
|
- spec/requests/resources_spec.rb
|
|
856
|
+
- spec/requests/schema_lookup_footprint_spec.rb
|
|
851
857
|
- spec/requests/smart_field_named_like_unexposed_relation_spec.rb
|
|
852
858
|
- spec/requests/stats_spec.rb
|
|
853
859
|
- spec/requests/sti_record_conversion_spec.rb
|
|
@@ -877,6 +883,7 @@ test_files:
|
|
|
877
883
|
- spec/services/forest_liana/line_stat_getter_spec.rb
|
|
878
884
|
- spec/services/forest_liana/pie_stat_getter_spec.rb
|
|
879
885
|
- spec/services/forest_liana/projection_parser_spec.rb
|
|
886
|
+
- spec/services/forest_liana/request_cache_spec.rb
|
|
880
887
|
- spec/services/forest_liana/resource_updater_spec.rb
|
|
881
888
|
- spec/services/forest_liana/resources_getter_composite_keys_spec.rb
|
|
882
889
|
- spec/services/forest_liana/resources_getter_spec.rb
|