Skip to content

Commit a96cbe7

Browse files
committed
Adding Fragment Cache to AMS
It's an upgrade based on the new Cache implementation rails-api#693. It allows to use the Rails conventions to cache specific attributes or associations. It's based on the Cache Composition implementation.
1 parent a824376 commit a96cbe7

File tree

11 files changed

+278
-36
lines changed

11 files changed

+278
-36
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ a ```key``` option that will be the prefix of the object cache
271271
on a pattern ```"#{key}/#{object.id}-#{object.updated_at}"```.
272272

273273
**[NOTE] Every object is individually cached.**
274+
274275
**[NOTE] The cache is automatically expired after update an object but it's not deleted.**
275276

276277
```ruby
@@ -294,6 +295,25 @@ On this example every ```Post``` object will be cached with
294295
the key ```"post/#{post.id}-#{post.updated_at}"```. You can use this key to expire it as you want,
295296
but in this case it will be automatically expired after 3 hours.
296297

298+
### Fragmenting Caching
299+
300+
If there is some API endpoint that shouldn't be fully cached, you can still optmise it, using Fragment Cache on the attributes and relationships that you want to cache.
301+
302+
You can define the attribute or relationships by using ```only``` or ```except``` option on cache method.
303+
304+
Example:
305+
306+
```ruby
307+
class PostSerializer < ActiveModel::Serializer
308+
cache key: 'post', expires_in: 3.hours, only: [:title]
309+
attributes :title, :body
310+
311+
has_many :comments
312+
313+
url :post
314+
end
315+
```
316+
297317
## Getting Help
298318

299319
If you find a bug, please report an [Issue](https://github.com/rails-api/active_model_serializers/issues/new).

lib/active_model/serializer.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,25 @@ class Serializer
88

99
class << self
1010
attr_accessor :_attributes
11+
attr_accessor :_attributes_keys
1112
attr_accessor :_associations
1213
attr_accessor :_urls
1314
attr_accessor :_cache
1415
attr_accessor :_cache_key
16+
attr_accessor :_cache_only
17+
attr_accessor :_cache_except
1518
attr_accessor :_cache_options
1619
end
1720

1821
def self.inherited(base)
1922
base._attributes = []
23+
base._attributes_keys = {}
2024
base._associations = {}
2125
base._urls = []
2226
end
2327

2428
def self.attributes(*attrs)
29+
attrs = attrs.first if attrs.first.class == Array
2530
@_attributes.concat attrs
2631

2732
attrs.each do |attr|
@@ -33,6 +38,7 @@ def self.attributes(*attrs)
3338

3439
def self.attribute(attr, options = {})
3540
key = options.fetch(:key, attr)
41+
@_attributes_keys[attr] = {key: key} if key != attr
3642
@_attributes.concat [key]
3743
define_method key do
3844
object.read_attribute_for_serialization(attr)
@@ -43,6 +49,8 @@ def self.attribute(attr, options = {})
4349
def self.cache(options = {})
4450
@_cache = ActionController::Base.cache_store if Rails.configuration.action_controller.perform_caching
4551
@_cache_key = options.delete(:key)
52+
@_cache_only = options.delete(:only)
53+
@_cache_except = options.delete(:except)
4654
@_cache_options = (options.empty?) ? nil : options
4755
end
4856

lib/active_model/serializer/adapter.rb

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
require 'active_model/serializer/fragment_cache'
2+
13
module ActiveModel
24
class Serializer
35
class Adapter
@@ -11,6 +13,7 @@ class Adapter
1113
def initialize(serializer, options = {})
1214
@serializer = serializer
1315
@options = options
16+
@klass = serializer.class
1417
end
1518

1619
def serializable_hash(options = {})
@@ -34,6 +37,30 @@ def self.adapter_class(adapter)
3437

3538
private
3639

40+
def cache_check
41+
if is_cached?
42+
@klass._cache.fetch(cache_key, @klass._cache_options) do
43+
yield
44+
end
45+
elsif is_fragment_cached?
46+
FragmentCache.new(self, serializer, @options, @root).fetch
47+
else
48+
yield
49+
end
50+
end
51+
52+
def is_cached?
53+
@klass._cache && !@klass._cache_only && !@klass._cache_except
54+
end
55+
56+
def is_fragment_cached?
57+
@klass._cache_only && !@klass._cache_except || !@klass._cache_only && @klass._cache_except
58+
end
59+
60+
def cache_key
61+
(@klass._cache_key) ? "#{@klass._cache_key}/#{serializer.object.id}-#{serializer.object.updated_at}" : serializer.object.cache_key
62+
end
63+
3764
def meta
3865
serializer.meta if serializer.respond_to?(:meta)
3966
end
@@ -50,20 +77,6 @@ def include_meta(json)
5077
json[meta_key] = meta if meta && root
5178
json
5279
end
53-
54-
private
55-
56-
def cached_object
57-
klass = serializer.class
58-
if klass._cache
59-
_cache_key = (klass._cache_key) ? "#{klass._cache_key}/#{serializer.object.id}-#{serializer.object.updated_at}" : serializer.object.cache_key
60-
klass._cache.fetch(_cache_key, klass._cache_options) do
61-
yield
62-
end
63-
else
64-
yield
65-
end
66-
end
6780
end
6881
end
6982
end

lib/active_model/serializer/adapter/json.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ def serializable_hash(options = {})
66
if serializer.respond_to?(:each)
77
@result = serializer.map{|s| self.class.new(s).serializable_hash }
88
else
9-
@result = cached_object do
9+
@result = cache_check do
1010
@hash = serializer.attributes(options)
1111
serializer.each_association do |name, association, opts|
1212
if association.respond_to?(:each)

lib/active_model/serializer/adapter/json_api.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def serializable_hash(options = {})
2323
self.class.new(s, @options.merge(top: @top, fieldset: @fieldset)).serializable_hash[@root]
2424
end
2525
else
26-
@hash = cached_object do
26+
@hash = cache_check do
2727
@hash[@root] = attributes_for_serializer(serializer, @options)
2828
add_resource_links(@hash[@root], serializer)
2929
@hash
@@ -91,7 +91,6 @@ def add_linked(resource_name, serializers, parent = nil)
9191
end
9292
end
9393

94-
9594
def attributes_for_serializer(serializer, options)
9695
if serializer.respond_to?(:each)
9796
result = []
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
module ActiveModel
2+
class Serializer
3+
class FragmentCache
4+
5+
attr_reader :serializer
6+
7+
def initialize(adapter, serializer, options, root)
8+
@root = root
9+
@options = options
10+
@adapter = adapter
11+
@serializer = serializer
12+
end
13+
14+
def fetch
15+
klass = serializer.class
16+
serializers = fragment_serializer(@serializer.object.class.name, klass)
17+
18+
cached_serializer = serializers[:cached].constantize.new(@serializer.object)
19+
non_cached_serializer = serializers[:non_cached].constantize.new(@serializer.object)
20+
21+
cached_hash = @adapter.class.new(cached_serializer, @options).serializable_hash
22+
non_cached_hash = @adapter.class.new(non_cached_serializer, @options).serializable_hash
23+
24+
if @serializer.root && @adapter.class == ActiveModel::Serializer::Adapter::JsonApi
25+
cached_hash_root(cached_hash, non_cached_hash)
26+
else
27+
non_cached_hash.merge cached_hash
28+
end
29+
end
30+
31+
private
32+
33+
def cached_hash_root(cached_hash, non_cached_hash)
34+
hash = {}
35+
36+
core_cached = cached_hash.first
37+
core_non_cached = non_cached_hash.first
38+
no_root_cache = cached_hash.delete_if {|key, value| key == core_cached[0] }
39+
no_root_non_cache = non_cached_hash.delete_if {|key, value| key == core_non_cached[0] }
40+
41+
if @root
42+
hash[@root] = (core_cached[1]) ? core_cached[1].merge(core_non_cached[1]) : core_non_cached[1]
43+
else
44+
hash = (core_cached[1]) ? core_cached[1].merge(core_non_cached[1]) : core_non_cached[1]
45+
end
46+
hash.merge no_root_non_cache.merge no_root_cache
47+
end
48+
49+
def fragment_associations(serializers, associations)
50+
associations.each do |association|
51+
options = ",#{association[1][:association_options]}" if association[1].include?(:association_options)
52+
eval("#{serializers[:non_cached]}.#{association[1][:type].to_s}(:#{association[0]}#{options})")
53+
end
54+
end
55+
56+
def cached_attributes_and_association(klass, serializers)
57+
cached_attr = (klass._cache_only) ? klass._cache_only : @serializer.attributes.keys.delete_if {|attr| klass._cache_except.include?(attr) }
58+
non_cached_attr = @serializer.attributes.keys.delete_if {|attr| cached_attr.include?(attr) }
59+
associations = @serializer.each_association
60+
61+
cached_attr.each do |attr|
62+
if @serializer.each_association.keys.include?(attr)
63+
associations.delete(attr)
64+
serializers[:cached].constantize.send(@serializer.each_association[attr][:type], attr)
65+
end
66+
end
67+
68+
cached_attr.each do |attribute|
69+
options = @serializer.class._attributes_keys[attribute]
70+
options ||= {}
71+
serializers[:cached].constantize.attribute(attribute, options)
72+
end
73+
74+
non_cached_attr.each do |attribute|
75+
options = @serializer.class._attributes_keys[attribute]
76+
options ||= {}
77+
serializers[:non_cached].constantize.attribute(attribute, options)
78+
end
79+
fragment_associations(serializers, associations)
80+
end
81+
82+
def fragment_serializer(name, klass)
83+
cached = "#{name.capitalize}CachedSerializer"
84+
non_cached = "#{name.capitalize}NonCachedSerializer"
85+
86+
Object.const_set cached, Class.new(ActiveModel::Serializer) unless Object.const_defined?(cached)
87+
Object.const_set non_cached, Class.new(ActiveModel::Serializer) unless Object.const_defined?(non_cached)
88+
89+
klass._cache_options ||= {}
90+
klass._cache_options[:key] = klass._cache_key if klass._cache_key
91+
cached.constantize.cache(klass._cache_options)
92+
93+
serializers = {cached: cached, non_cached: non_cached}
94+
cached_attributes_and_association(klass, serializers)
95+
return serializers
96+
end
97+
end
98+
end
99+
end

test/action_controller/json_api_linked_test.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,14 @@ def test_render_resource_with_nested_has_many_include
110110
"roles"=>[{
111111
"id" => "1",
112112
"name" => "admin",
113+
"description" => nil,
113114
"links" => {
114115
"author" => "1"
115116
}
116117
}, {
117118
"id" => "2",
118119
"name" => "colab",
120+
"description" => nil,
119121
"links" => {
120122
"author" => "1"
121123
}

test/action_controller/serialization_test.rb

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def render_object_expired_with_cache_enabled
6969
generate_cached_serializer(post)
7070

7171
post.title = 'ZOMG a New Post'
72-
sleep 0.05
72+
sleep 0.1
7373
render json: post
7474
end
7575

@@ -81,6 +81,42 @@ def render_changed_object_with_cache_enabled
8181
render json: post
8282
end
8383

84+
def render_fragment_changed_object_with_only_cache_enabled
85+
author = Author.new(id: 1, name: 'Joao Moura.')
86+
role = Role.new({ id: 42, name: 'ZOMG A ROLE', description: 'DESCRIPTION HERE', author: author })
87+
88+
generate_cached_serializer(role)
89+
role.name = 'lol'
90+
role.description = 'HUEHUEBRBR'
91+
92+
render json: role
93+
end
94+
95+
def render_fragment_changed_object_with_except_cache_enabled
96+
author = Author.new(id: 1, name: 'Joao Moura.')
97+
bio = Bio.new({ id: 42, content: 'ZOMG A ROLE', rating: 5, author: author })
98+
99+
generate_cached_serializer(bio)
100+
bio.content = 'lol'
101+
bio.rating = 0
102+
103+
render json: bio
104+
end
105+
106+
def render_fragment_changed_object_with_relationship
107+
comment = Comment.new({ id: 1, body: 'ZOMG A COMMENT' })
108+
author = Author.new(id: 1, name: 'Joao Moura.')
109+
post = Post.new({ id: 1, title: 'New Post', blog:nil, body: 'Body', comments: [comment], author: author })
110+
post2 = Post.new({ id: 1, title: 'New Post2', blog:nil, body: 'Body2', comments: [comment], author: author })
111+
like = Like.new({ id: 1, post: post, time: 3.days.ago })
112+
113+
generate_cached_serializer(like)
114+
like.post = post2
115+
like.time = DateTime.now.to_s
116+
117+
render json: like
118+
end
119+
84120
private
85121
def generate_cached_serializer(obj)
86122
serializer_class = ActiveModel::Serializer.serializer_for(obj)
@@ -200,6 +236,45 @@ def test_render_with_cache_enable_and_expired
200236
assert_equal 'application/json', @response.content_type
201237
assert_equal expected.to_json, @response.body
202238
end
239+
240+
def test_render_with_fragment_only_cache_enable
241+
ActionController::Base.cache_store.clear
242+
get :render_fragment_changed_object_with_only_cache_enabled
243+
response = JSON.parse(@response.body)
244+
245+
assert_equal 'application/json', @response.content_type
246+
assert_equal 'ZOMG A ROLE', response["name"]
247+
assert_equal 'HUEHUEBRBR', response["description"]
248+
end
249+
250+
def test_render_with_fragment_except_cache_enable
251+
ActionController::Base.cache_store.clear
252+
get :render_fragment_changed_object_with_except_cache_enabled
253+
response = JSON.parse(@response.body)
254+
255+
assert_equal 'application/json', @response.content_type
256+
assert_equal 5, response["rating"]
257+
assert_equal 'lol', response["content"]
258+
end
259+
260+
def test_render_fragment_changed_object_with_relationship
261+
ActionController::Base.cache_store.clear
262+
get :render_fragment_changed_object_with_relationship
263+
response = JSON.parse(@response.body)
264+
265+
expected_return = {
266+
"post" => {
267+
"id"=>1,
268+
"title"=>"New Post",
269+
"body"=>"Body"
270+
},
271+
"id"=>1,
272+
"time"=>DateTime.now.to_s
273+
}
274+
275+
assert_equal 'application/json', @response.content_type
276+
assert_equal expected_return, response
277+
end
203278
end
204279
end
205-
end
280+
end

0 commit comments

Comments
 (0)