Fix cache keys to prevent fieldset caching errors

This change alters the cache namespace prior to retrieving cached record
data to ensure that different fieldsets are given different cache keys.

Previously, all cache keys for the same record would be specified
identically, leading to a situation where the fieldset would be ignored
if record caching is enabled.

Fixes #90.
This commit is contained in:
Matthew Newell 2020-08-10 11:43:23 -05:00 committed by Stas
parent 1ce4677a22
commit 8401d16c2e
4 changed files with 92 additions and 2 deletions

View File

@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Remove `ObjectSerializer#serialized_json` (#91)
### Fixed
- Ensure caching correctly incorporates fieldset information into the cache key to prevent incorrect fieldset caching (#90)
## [1.7.2] - 2020-05-18
### Fixed
- Relationship#record_type_for does not assign static record type for polymorphic relationships (#83)

View File

@ -409,6 +409,25 @@ So for the example above it will call the cache instance like this:
Rails.cache.fetch(record, namespace: 'jsonapi-serializer', expires_in: 1.hour) { ... }
```
#### Caching and Sparse Fieldsets
If caching is enabled and fields are provided to the serializer, the fieldset will be appended to the cache key's namespace.
For example, given the following serializer definition and instance:
```ruby
class ActorSerializer
include JSONAPI::Serializer
attributes :first_name, :last_name
cache_options store: Rails.cache, namespace: 'jsonapi-serializer', expires_in: 1.hour
end
serializer = ActorSerializer.new(actor, { fields: { actor: [:first_name] } })
```
The following cache namespace will be generated: `'jsonapi-serializer-fieldset:first_name'`.
### Params
In some cases, attribute values might require more information than what is
@ -469,7 +488,7 @@ class MovieSerializer
# The director will be serialized only if the :admin key of params is true
params && params[:admin] == true
}
# Custom attribute `name_year` will only be serialized if both `name` and `year` fields are present
attribute :name_year, if: Proc.new { |record|
record.name.present? && record.year.present?

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'active_support/concern'
require 'digest/sha1'
module FastJsonapi
MandatoryField = Class.new(StandardError)
@ -66,7 +67,7 @@ module FastJsonapi
def record_hash(record, fieldset, includes_list, params = {})
if cache_store_instance
record_hash = cache_store_instance.fetch(record, **cache_store_options) do
record_hash = cache_store_instance.fetch(record, **cache_options_with_fieldsets(cache_store_options, fieldset)) do
temp_hash = id_hash(id_from_record(record, params), record_type, true)
temp_hash[:attributes] = attributes_hash(record, fieldset, params) if attributes_to_serialize.present?
temp_hash[:relationships] = {}
@ -86,6 +87,32 @@ module FastJsonapi
record_hash
end
#
# It modifies cache options to include fieldset information in the cache namespace.
#
# If a fieldset is specified, it modifies the namespace to include the fields from the fieldset.
#
# If no fieldset is specified, the namespace will be unaltered.
#
# @param [Hash] options cache options hash
# @param [Array, nil] fieldset fieldset array or nil if unspecified
#
# @return [Hash] processed options hash
#
def cache_options_with_fieldsets(options, fieldset)
return options unless fieldset
options = options ? options.dup : {}
options[:namespace] ||= 'jsonapi-serializer'
fieldset_key = fieldset.join('_')
# Use a fixed-length fieldset key if the current length is more than the length of a SHA1 digest
fieldset_key = Digest::SHA1.hexdigest(fieldset_key) if fieldset_key.length > 40
options[:namespace] = "#{options[:namespace]}-fieldset:#{fieldset_key}"
options
end
def id_from_record(record, params)
return FastJsonapi.call_proc(record_id, record, params) if record_id.is_a?(Proc)
return record.send(record_id) if record_id

View File

@ -26,4 +26,45 @@ RSpec.describe FastJsonapi::ObjectSerializer do
).to be(false)
end
end
describe 'with caching and different fieldsets' do
context 'when fieldset is provided' do
it 'includes the fieldset in the namespace' do
expect(cache_store.delete(actor, namespace: 'test')).to be(false)
Cached::ActorSerializer.new(
[actor], fields: { actor: %i[first_name] }
).serializable_hash
# Expect cached keys to match the passed fieldset
expect(cache_store.read(actor, namespace: 'test-fieldset:first_name')[:attributes].keys).to eq(%i[first_name])
Cached::ActorSerializer.new(
[actor]
).serializable_hash
# Expect cached keys to match all valid actor fields (no fieldset)
expect(cache_store.read(actor, namespace: 'test')[:attributes].keys).to eq(%i[first_name last_name email])
expect(cache_store.delete(actor, namespace: 'test')).to be(true)
expect(cache_store.delete(actor, namespace: 'test-fieldset:first_name')).to be(true)
end
end
context 'when long fieldset is provided' do
let(:actor_keys) { %i[first_name last_name more_fields yet_more_fields so_very_many_fields] }
let(:digest_key) { Digest::SHA1.hexdigest(actor_keys.join('_')) }
it 'includes the hashed fieldset in the namespace' do
Cached::ActorSerializer.new(
[actor], fields: { actor: actor_keys }
).serializable_hash
expect(cache_store.read(actor, namespace: "test-fieldset:#{digest_key}")[:attributes].keys).to eq(
%i[first_name last_name]
)
expect(cache_store.delete(actor, namespace: "test-fieldset:#{digest_key}")).to be(true)
end
end
end
end