Skip to content

Commit 3b1acee

Browse files
authored
Merge pull request #1023 from DavidMikeSimon/custom-records-method-with-caching
Fixed cache pollution bug
2 parents 76c5e08 + 544a536 commit 3b1acee

File tree

7 files changed

+161
-124
lines changed

7 files changed

+161
-124
lines changed

lib/jsonapi/active_record_accessor.rb

Lines changed: 115 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -112,25 +112,25 @@ def foreign_keys(resource, relationship_name, options = {})
112112
def find_serialized_with_caching(filters_or_source, serializer, options = {})
113113
if filters_or_source.is_a?(ActiveRecord::Relation)
114114
return cached_resources_for(filters_or_source, serializer, options)
115-
elsif _resource_klass._model_class.respond_to?(:all) && _resource_klass._model_class.respond_to?(:arel_table)
115+
elsif resource_class_based_on_active_record?(_resource_klass)
116116
records = find_records(filters_or_source, options.except(:include_directives))
117117
return cached_resources_for(records, serializer, options)
118118
else
119119
# :nocov:
120-
warn('Caching enabled on model that does not support ActiveRelation')
120+
warn('Caching enabled on model not based on ActiveRecord API or similar')
121121
# :nocov:
122122
end
123123
end
124124

125125
def find_by_key_serialized_with_caching(key, serializer, options = {})
126-
if _resource_klass._model_class.respond_to?(:all) && _resource_klass._model_class.respond_to?(:arel_table)
126+
if resource_class_based_on_active_record?(_resource_klass)
127127
results = find_serialized_with_caching({ _resource_klass._primary_key => key }, serializer, options)
128128
result = results.first
129129
fail JSONAPI::Exceptions::RecordNotFound.new(key) if result.nil?
130130
return result
131131
else
132132
# :nocov:
133-
warn('Caching enabled on model that does not support ActiveRelation')
133+
warn('Caching enabled on model not based on ActiveRecord API or similar')
134134
# :nocov:
135135
end
136136
end
@@ -339,7 +339,14 @@ def cached_resources_for(records, serializer, options)
339339
resources = _resource_klass.resources_for(records, options[:context]).map { |r| [r.id, r] }.to_h
340340
end
341341

342-
preload_included_fragments(resources, records, serializer, options)
342+
if options[:include_directives]
343+
resource_pile = { _resource_klass.name => resources }
344+
options[:include_directives].all_paths.each do |path|
345+
# Note that `all_paths` returns shorter paths first, so e.g. the partial fragments for
346+
# posts.comments will exist before we start working with posts.comments.author
347+
preload_included_fragments(_resource_klass, resource_pile, path, serializer, options)
348+
end
349+
end
343350

344351
resources.values
345352
end
@@ -366,134 +373,121 @@ def find_records(filters, options = {})
366373
end
367374
end
368375

369-
def preload_included_fragments(resources, records, serializer, options)
370-
return if resources.empty?
371-
res_ids = resources.keys
376+
def preload_included_fragments(src_res_class, resource_pile, path, serializer, options)
377+
src_resources = resource_pile[src_res_class.name]
378+
return if src_resources.nil? || src_resources.empty?
379+
380+
rel_name = path.first
381+
relationship = src_res_class._relationships[rel_name]
382+
if relationship.polymorphic
383+
# FIXME Preloading through a polymorphic belongs_to association is not implemented.
384+
# For now, in this case, ResourceSerializer will have to do the fetch itself, without
385+
# using either the cache or eager-loading.
386+
return
387+
end
372388

373-
include_directives = options[:include_directives]
374-
return unless include_directives
375-
376-
context = options[:context]
377-
378-
# For each association, including indirect associations, find the target record ids.
379-
# Even if a target class doesn't have caching enabled, we still have to look up
380-
# and match the target ids here, because we can't use ActiveRecord#includes.
381-
#
382-
# Note that `paths` returns partial paths before complete paths, so e.g. the partial
383-
# fragments for posts.comments will exist before we start working with posts.comments.author
384-
target_resources = {}
385-
include_directives.paths.each do |path|
386-
# If path is [:posts, :comments, :author], then...
387-
pluck_attrs = [] # ...will be [posts.id, comments.id, authors.id, authors.updated_at]
388-
pluck_attrs << _resource_klass._model_class.arel_table[_resource_klass._primary_key]
389-
390-
relation = records
391-
.except(:limit, :offset, :order)
392-
.where({ _resource_klass._primary_key => res_ids })
393-
394-
# These are updated as we iterate through the association path; afterwards they will
395-
# refer to the final resource on the path, i.e. the actual resource to find in the cache.
396-
# So e.g. if path is [:posts, :comments, :author], then after iteration...
397-
parent_klass = nil # Comment
398-
klass = _resource_klass # Person
399-
relationship = nil # JSONAPI::Relationship::ToOne for CommentResource.author
400-
table = nil # people
401-
assocs_path = [] # [ :posts, :approved_comments, :author ]
402-
ar_hash = nil # { :posts => { :approved_comments => :author } }
403-
404-
# For each step on the path, figure out what the actual table name/alias in the join
405-
# will be, and include the primary key of that table in our list of fields to select
406-
non_polymorphic = true
407-
path.each do |elem|
408-
relationship = klass._relationships[elem]
409-
if relationship.polymorphic
410-
# Can't preload through a polymorphic belongs_to association, ResourceSerializer
411-
# will just have to bypass the cache and load the real Resource.
412-
non_polymorphic = false
413-
break
414-
end
415-
assocs_path << relationship.relation_name(options).to_sym
416-
# Converts [:a, :b, :c] to Rails-style { :a => { :b => :c }}
417-
ar_hash = assocs_path.reverse.reduce { |memo, step| { step => memo } }
418-
# We can't just look up the table name from the resource class, because Arel could
419-
# have used a table alias if the relation includes a self-reference.
420-
join_source = relation.joins(ar_hash).arel.source.right.reverse.find do |arel_node|
421-
arel_node.is_a?(Arel::Nodes::InnerJoin)
422-
end
423-
table = join_source.left
424-
parent_klass = klass
425-
klass = relationship.resource_klass
426-
pluck_attrs << table[klass._primary_key]
427-
end
428-
next unless non_polymorphic
429-
430-
# Pre-fill empty hashes for each resource up to the end of the path.
431-
# This allows us to later distinguish between a preload that returned nothing
432-
# vs. a preload that never ran.
433-
prefilling_resources = resources.values
434-
path.each do |rel_name|
435-
rel_name = serializer.key_formatter.format(rel_name)
436-
prefilling_resources.map! do |res|
437-
res.preloaded_fragments[rel_name] ||= {}
438-
res.preloaded_fragments[rel_name].values
439-
end
440-
prefilling_resources.flatten!(1)
441-
end
389+
tgt_res_class = relationship.resource_klass
390+
unless resource_class_based_on_active_record?(tgt_res_class)
391+
# Can't preload relationships from non-AR resources, this association will be filled
392+
# in on-demand later by ResourceSerializer.
393+
return
394+
end
442395

443-
pluck_attrs << table[klass._cache_field] if klass.caching?
444-
relation = relation.joins(ar_hash)
445-
if relationship.is_a?(JSONAPI::Relationship::ToMany)
446-
# Rails doesn't include order clauses in `joins`, so we have to add that manually here.
447-
# FIXME Should find a better way to reflect on relationship ordering. :-(
448-
relation = relation.order(parent_klass._model_class.new.send(assocs_path.last).arel.orders)
449-
end
396+
# Assume for longer paths that the intermediate fragments have already been preloaded
397+
if path.length > 1
398+
preload_included_fragments(tgt_res_class, resource_pile, path.drop(1), serializer, options)
399+
return
400+
end
450401

451-
# [[post id, comment id, author id, author updated_at], ...]
452-
id_rows = pluck_arel_attributes(relation.joins(ar_hash), *pluck_attrs)
402+
record_source = src_res_class._model_class
403+
.where({ src_res_class._primary_key => src_resources.keys })
404+
.joins(relationship.relation_name(options).to_sym)
453405

454-
target_resources[klass.name] ||= {}
406+
if relationship.is_a?(JSONAPI::Relationship::ToMany)
407+
# Rails doesn't include order clauses in `joins`, so we have to add that manually here.
408+
# FIXME Should find a better way to reflect on relationship ordering. :-(
409+
fake_model_instance = src_res_class._model_class.new
410+
record_source = record_source.order(fake_model_instance.send(rel_name).arel.orders)
411+
end
455412

456-
if klass.caching?
457-
sub_cache_ids = id_rows
458-
.map { |row| row.last(2) }
459-
.reject { |row| target_resources[klass.name].has_key?(row.first) }
460-
.uniq
461-
target_resources[klass.name].merge! CachedResourceFragment.fetch_fragments(
462-
klass, serializer, context, sub_cache_ids
463-
)
464-
else
465-
sub_res_ids = id_rows
466-
.map(&:last)
467-
.reject { |id| target_resources[klass.name].has_key?(id) }
468-
.uniq
469-
found = klass.find({ klass._primary_key => sub_res_ids }, context: options[:context])
470-
target_resources[klass.name].merge! found.map { |r| [r.id, r] }.to_h
471-
end
413+
# Pre-fill empty fragment hashes.
414+
# This allows us to later distinguish between a preload that returned nothing
415+
# vs. a preload that never ran.
416+
serialized_rel_name = serializer.key_formatter.format(rel_name)
417+
src_resources.each do |key, res|
418+
res.preloaded_fragments[serialized_rel_name] ||= {}
419+
end
472420

473-
id_rows.each do |row|
474-
res = resources[row.first]
475-
path.each_with_index do |rel_name, index|
476-
rel_name = serializer.key_formatter.format(rel_name)
477-
rel_id = row[index+1]
478-
assoc_rels = res.preloaded_fragments[rel_name]
479-
if index == path.length - 1
480-
assoc_rels[rel_id] = target_resources[klass.name].fetch(rel_id)
481-
else
482-
res = assoc_rels[rel_id]
483-
end
484-
end
485-
end
421+
# We can't just look up the table name from the target class, because Arel could
422+
# have used a table alias if the relation is a self-reference.
423+
join_node = record_source.arel.source.right.reverse.find do |arel_node|
424+
arel_node.is_a?(Arel::Nodes::InnerJoin)
425+
end
426+
tgt_table = join_node.left
427+
428+
# Resource class may restrict current user to a subset of available records
429+
if tgt_res_class.respond_to?(:records)
430+
valid_tgts_rel = tgt_res_class.records(options)
431+
valid_tgts_rel = valid_tgts_rel.all if valid_tgts_rel.respond_to?(:all)
432+
conn = valid_tgts_rel.connection
433+
tgt_attr = tgt_table[tgt_res_class._primary_key]
434+
435+
# Alter a normal AR query to select only the primary key instead of all columns.
436+
# Sadly doing direct string manipulation of query here, cannot use ARel for this due to
437+
# bind values being stripped from AR::Relation#arel in Rails >= 4.2, see
438+
# https://github.com/rails/arel/issues/363
439+
valid_tgts_query = valid_tgts_rel.to_sql.sub('*', conn.quote_column_name(tgt_attr.name))
440+
valid_tgts_cond = "#{quote_arel_attribute(conn, tgt_attr)} IN (#{valid_tgts_query})"
441+
442+
record_source = record_source.where(valid_tgts_cond)
443+
end
444+
445+
pluck_attrs = [
446+
src_res_class._model_class.arel_table[src_res_class._primary_key],
447+
tgt_table[tgt_res_class._primary_key]
448+
]
449+
pluck_attrs << tgt_table[tgt_res_class._cache_field] if tgt_res_class.caching?
450+
451+
id_rows = pluck_arel_attributes(record_source, *pluck_attrs)
452+
453+
target_resources = resource_pile[tgt_res_class.name] ||= {}
454+
455+
if tgt_res_class.caching?
456+
sub_cache_ids = id_rows.map{ |row| row.last(2) }.uniq.reject{|p| target_resources.has_key?(p[0]) }
457+
target_resources.merge! CachedResourceFragment.fetch_fragments(
458+
tgt_res_class, serializer, options[:context], sub_cache_ids
459+
)
460+
else
461+
sub_res_ids = id_rows.map(&:last).uniq - target_resources.keys
462+
recs = tgt_res_class.find({ tgt_res_class._primary_key => sub_res_ids }, context: options[:context])
463+
target_resources.merge!(recs.map{ |r| [r.id, r] }.to_h)
464+
end
465+
466+
id_rows.each do |row|
467+
src_id, tgt_id = row[0], row[1]
468+
src_res = src_resources[src_id]
469+
next unless src_res
470+
fragment = target_resources[tgt_id]
471+
next unless fragment
472+
src_res.preloaded_fragments[serialized_rel_name][tgt_id] = fragment
486473
end
487474
end
488475

489476
def pluck_arel_attributes(relation, *attrs)
490477
conn = relation.connection
491-
quoted_attrs = attrs.map do |attr|
492-
quoted_table = conn.quote_table_name(attr.relation.table_alias || attr.relation.name)
493-
quoted_column = conn.quote_column_name(attr.name)
494-
"#{quoted_table}.#{quoted_column}"
495-
end
478+
quoted_attrs = attrs.map{|attr| quote_arel_attribute(conn, attr) }
496479
relation.pluck(*quoted_attrs)
497480
end
481+
482+
def quote_arel_attribute(connection, attr)
483+
quoted_table = connection.quote_table_name(attr.relation.table_alias || attr.relation.name)
484+
quoted_column = connection.quote_column_name(attr.name)
485+
"#{quoted_table}.#{quoted_column}"
486+
end
487+
488+
def resource_class_based_on_active_record?(klass)
489+
model_class = klass._model_class
490+
model_class.respond_to?(:all) && model_class.respond_to?(:arel_table)
491+
end
498492
end
499493
end

lib/jsonapi/include_directives.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def model_includes
3636
get_includes(@include_directives_hash)
3737
end
3838

39-
def paths
39+
def all_paths
4040
delve_paths(get_includes(@include_directives_hash, false))
4141
end
4242

lib/jsonapi/resource_serializer.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ def cached_relationships_hash(source, include_directives)
323323

324324
real_res = nil
325325
relationships.each do |rel_name, relationship|
326-
key = @key_formatter.format(rel_name)
326+
key = format_key(rel_name)
327327
to_many = relationship.is_a? JSONAPI::Relationship::ToMany
328328

329329
ia = include_directives[:include_related][rel_name]

test/controllers/controller_test.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3667,6 +3667,23 @@ def test_show_author_recursive
36673667
end
36683668
end
36693669

3670+
class Api::V2::AuthorsControllerTest < ActionController::TestCase
3671+
def test_cache_pollution_for_non_admin_indirect_access_to_banned_books
3672+
cache = ActiveSupport::Cache::MemoryStore.new
3673+
with_resource_caching(cache) do
3674+
$test_user = Person.find(5)
3675+
get :show, params: {id: '2', include: 'books'}
3676+
assert_response :success
3677+
assert_equal 2, json_response['included'].length
3678+
3679+
$test_user = Person.find(1)
3680+
get :show, params: {id: '2', include: 'books'}
3681+
assert_response :success
3682+
assert_equal 1, json_response['included'].length
3683+
end
3684+
end
3685+
end
3686+
36703687
class Api::BoxesControllerTest < ActionController::TestCase
36713688
def test_complex_includes_base
36723689
assert_cacheable_get :index

test/fixtures/active_record.rb

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,9 @@ class LikesController < JSONAPI::ResourceController
798798

799799
module V2
800800
class AuthorsController < JSONAPI::ResourceController
801+
def context
802+
{current_user: $test_user}
803+
end
801804
end
802805

803806
class PeopleController < JSONAPI::ResourceController
@@ -1448,6 +1451,24 @@ class PreferencesResource < PreferencesResource; end
14481451
class PersonResource < PersonResource; end
14491452
class PostResource < PostResource; end
14501453

1454+
class AuthorResource < JSONAPI::Resource
1455+
model_name 'Person'
1456+
attributes :name
1457+
1458+
has_many :books, inverse_relationship: :authors
1459+
1460+
def records_for(rel_name)
1461+
records = _model.public_send(rel_name)
1462+
if rel_name == :books
1463+
# Hide indirect access to banned books unless current user is a book admin
1464+
unless context[:current_user].try(:book_admin)
1465+
records = records.where(banned: false)
1466+
end
1467+
end
1468+
return records
1469+
end
1470+
end
1471+
14511472
class BookResource < JSONAPI::Resource
14521473
attribute :title
14531474
attributes :isbn, :banned
@@ -1483,7 +1504,7 @@ def records(options = {})
14831504
context = options[:context]
14841505
current_user = context ? context[:current_user] : nil
14851506

1486-
records = _model_class
1507+
records = _model_class.all
14871508
# Hide the banned books from people who are not book admins
14881509
unless current_user && current_user.book_admin
14891510
records = records.where(not_banned_books)

test/fixtures/book_authors.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,7 @@ book_author_2_1:
99
book_author_2_2:
1010
book_id: 2
1111
person_id: 2
12+
13+
book_author_654_2:
14+
book_id: 654 # Banned book
15+
person_id: 2

test/test_helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ class CatResource < JSONAPI::Resource
289289

290290
jsonapi_resource :preferences, except: [:create, :destroy]
291291

292+
jsonapi_resources :authors
292293
jsonapi_resources :books
293294
jsonapi_resources :book_comments
294295
end

0 commit comments

Comments
 (0)