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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 345a74b2a7088f0c84717c3023f50fef3c0918db97d392e936047abfb57a3b48
4
- data.tar.gz: 9abe4f90a54bddf3df9cf4cd73db258ef60962ec9bf5353904dd916676b15aac
3
+ metadata.gz: e9584149ffb7d20be9b8f4dacd36f5bf1d95c5276b1b34f35d86e750e0f696f2
4
+ data.tar.gz: 2d24e798d72ec62aabec3ebe9fdb070f857c8e511b02d4bbe8e8ce1880e03032
5
5
  SHA512:
6
- metadata.gz: 1f096e618b492ecea05c88063f3deb647a8928deaf524c95b35572061db3c7094c34484c492134dfdcf18d52f0897d7fa9d9f1a645ff419cfbb18489681532c0
7
- data.tar.gz: 2d818ad0f0e90d95f5574643a2b50d94615ec0f5601b9e10d8477f250ddd6e0ab9c34f6a23d0d9e470f69dbb76861fddf99b26a45b20449262a0251b08eaada6
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 = 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)
@@ -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 deletes the cluster-wide `forest.collections` entry, so a field that is merely
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 = 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
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
- users
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
- Rails.cache.write('forest.collections', fetch_collections_permissions, expires_in: TTL) if force_fetch == true
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
- Rails.cache.fetch('forest.collections', expires_in: TTL) { fetch_collections_permissions }
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
- Rails.cache.write('forest.stats', fetch_chart_data(rendering_id), expires_in: TTL) if force_fetch == true
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
- Rails.cache.fetch('forest.stats', expires_in: TTL) { fetch_chart_data(rendering_id) }
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
- Rails.cache.fetch('forest.has_permission') do
301
- (get_permissions('/liana/v4/permissions/environment') == true) ? false : true
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
- .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.
@@ -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.12"
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
@@ -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 = 'jd@forestadmin.com'"
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.11
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-10 00:00:00.000000000 Z
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