Skip to content

Commit 22d6ae7

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 22d6ae7

File tree

9 files changed

+219
-18
lines changed

9 files changed

+219
-18
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 optmize 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 mehtod.
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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ class << self
1212
attr_accessor :_urls
1313
attr_accessor :_cache
1414
attr_accessor :_cache_key
15+
attr_accessor :_cache_only
16+
attr_accessor :_cache_except
1517
attr_accessor :_cache_options
1618
end
1719

@@ -22,6 +24,7 @@ def self.inherited(base)
2224
end
2325

2426
def self.attributes(*attrs)
27+
attrs = attrs.first if attrs.first.class.name == 'Array'
2528
@_attributes.concat attrs
2629

2730
attrs.each do |attr|
@@ -43,6 +46,8 @@ def self.attribute(attr, options = {})
4346
def self.cache(options = {})
4447
@_cache = ActionController::Base.cache_store if Rails.configuration.action_controller.perform_caching
4548
@_cache_key = options.delete(:key)
49+
@_cache_only = options.delete(:only)
50+
@_cache_except = options.delete(:except)
4651
@_cache_options = (options.empty?) ? nil : options
4752
end
4853

lib/active_model/serializer/adapter.rb

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,89 @@ def include_meta(json)
5555

5656
def cached_object
5757
klass = serializer.class
58-
if klass._cache
58+
if klass._cache && !klass._cache_only && !klass._cache_except
5959
_cache_key = (klass._cache_key) ? "#{klass._cache_key}/#{serializer.object.id}-#{serializer.object.updated_at}" : serializer.object.cache_key
6060
klass._cache.fetch(_cache_key, klass._cache_options) do
6161
yield
6262
end
63+
64+
elsif klass._cache_only && !klass._cache_except || !klass._cache_only && klass._cache_except
65+
serializers = fragment_serializer(@serializer.object.class.name, klass)
66+
cached_elements = cached_attributes_and_association(klass, serializers)
67+
fragment_associations(serializers, cached_elements[:associations])
68+
69+
cached_adapter = self.class.new(serializers[:cached].constantize.new(@serializer.object), @options)
70+
non_cached_adapter = self.class.new(serializers[:non_cached].constantize.new(@serializer.object), @options)
71+
72+
cached_hash = cached_adapter.serializable_hash
73+
non_cached_hash = non_cached_adapter.serializable_hash
74+
75+
if @serializer.root || self.class == ActiveModel::Serializer::Adapter::JsonApi
76+
cached_hash_root(cached_hash, non_cached_hash)
77+
else
78+
cached_hash.merge non_cached_hash
79+
end
6380
else
6481
yield
6582
end
6683
end
84+
85+
def cached_hash_root(cached_hash, non_cached_hash)
86+
@root
87+
hash = {}
88+
89+
core_cached = cached_hash.first
90+
core_non_cached = non_cached_hash.first
91+
92+
no_root_cache = cached_hash.delete_if {|key, value| key == core_cached[0] }
93+
no_root_non_cache = non_cached_hash.delete_if {|key, value| key == core_non_cached[0] }
94+
95+
if @root
96+
hash[@root] = (core_cached[1]) ? core_cached[1].merge(core_non_cached[1]) : core_non_cached[1]
97+
else
98+
hash = (core_cached[1]) ? core_cached[1].merge(core_non_cached[1]) : core_non_cached[1]
99+
end
100+
101+
hash.merge no_root_cache.merge no_root_non_cache
102+
end
103+
104+
def fragment_associations(serializers, associations)
105+
associations.each do |association|
106+
options = ",#{association[1][:association_options]}" if association[1].include?(:association_options)
107+
eval("#{serializers[:non_cached]}.#{association[1][:type].to_s}(:#{association[0]}#{options})")
108+
end
109+
end
110+
111+
def cached_attributes_and_association(klass, serializers)
112+
cached_attr = (klass._cache_only) ? klass._cache_only : @serializer.attributes.keys.delete_if {|attr| klass._cache_except.include?(attr) }
113+
non_cached_attr = @serializer.attributes.keys.delete_if {|attr| cached_attr.include?(attr) }
114+
associations = @serializer.each_association
115+
116+
cached_attr.each do |attr|
117+
if @serializer.each_association.keys.include?(attr)
118+
associations.delete(attr)
119+
serializers[:cached].constantize.send(@serializer.each_association[attr][:type], attr)
120+
end
121+
end
122+
123+
serializers[:cached].constantize.attributes(cached_attr)
124+
serializers[:non_cached].constantize.attributes(non_cached_attr)
125+
return {attributes: cached_attr, associations: associations}
126+
end
127+
128+
def fragment_serializer(name, klass)
129+
cached = "#{name.capitalize}CachedSerializer"
130+
non_cached = "#{name.capitalize}NonCachedSerializer"
131+
132+
Object.const_set cached, Class.new(ActiveModel::Serializer) unless Object.const_defined?(cached)
133+
Object.const_set non_cached, Class.new(ActiveModel::Serializer) unless Object.const_defined?(non_cached)
134+
135+
klass._cache_options ||= {}
136+
klass._cache_options[:key] = klass._cache_key if klass._cache_key
137+
cached.constantize.cache(klass._cache_options)
138+
139+
{cached: cached, non_cached: non_cached}
140+
end
67141
end
68142
end
69143
end

lib/active_model/serializer/adapter/json_api.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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 = []

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: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

test/adapter/json_api/linked_test.rb

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ class Adapter
66
class JsonApi
77
class LinkedTest < Minitest::Test
88
def setup
9+
ActionController::Base.cache_store.clear
910
@author1 = Author.new(id: 1, name: 'Steve K.')
1011
@author2 = Author.new(id: 2, name: 'Tenderlove')
1112
@bio1 = Bio.new(id: 1, content: 'AMS Contributor')
@@ -32,7 +33,7 @@ def setup
3233
@bio2.author = @author2
3334
end
3435

35-
def test_include_multiple_posts_and_linked
36+
def test_include_multiple_posts_and_linked_serializer
3637
@serializer = ArraySerializer.new([@first_post, @second_post])
3738
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'author,author.bio,comments')
3839

@@ -44,8 +45,8 @@ def test_include_multiple_posts_and_linked
4445
@second_comment.post = @first_post
4546
@second_comment.author = nil
4647
assert_equal([
47-
{ title: "Hello!!", body: "Hello, world!!", id: "1", links: { comments: ['1', '2'], author: "1" } },
48-
{ title: "New Post", body: "Body", id: "2", links: { comments: [], :author => "2" } }
48+
{ :id=>"1", :title=>"Hello!!", :body=>"Hello, world!!", :links=>{:comments=>["1", "2"], :blog=>"999", :author=>"1"} },
49+
{ :id=>"2", :title=>"New Post", :body=>"Body", :links=>{:comments=>[], :blog=>"999", :author=>"2"} }
4950
], @adapter.serializable_hash[:posts])
5051

5152

@@ -69,7 +70,7 @@ def test_include_multiple_posts_and_linked
6970
id: "1",
7071
name: "Steve K.",
7172
links: {
72-
posts: ["1"],
73+
posts: ["1", "3"],
7374
roles: [],
7475
bio: "1"
7576
}
@@ -85,12 +86,14 @@ def test_include_multiple_posts_and_linked
8586
bios: [{
8687
id: "1",
8788
content: "AMS Contributor",
89+
rating: nil,
8890
links: {
8991
author: "1"
9092
}
9193
}, {
9294
id: "2",
9395
content: "Rails Contributor",
96+
rating: nil,
9497
links: {
9598
author: "2"
9699
}
@@ -99,9 +102,9 @@ def test_include_multiple_posts_and_linked
99102
assert_equal expected, @adapter.serializable_hash[:linked]
100103
end
101104

102-
def test_include_multiple_posts_and_linked
105+
def test_include_multiple_posts_and_linked_array_serializer
103106
@serializer = BioSerializer.new(@bio1)
104-
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'author,author.posts')
107+
@serializer.class.config.adapter = :json_api
105108

106109
@first_comment = Comment.new(id: 1, body: 'ZOMG A COMMENT')
107110
@second_comment = Comment.new(id: 2, body: 'ZOMG ANOTHER COMMENT')
@@ -111,6 +114,7 @@ def test_include_multiple_posts_and_linked
111114
@first_comment.author = nil
112115
@second_comment.post = @first_post
113116
@second_comment.author = nil
117+
@adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'author,author.posts')
114118

115119
expected = {
116120
authors: [{
@@ -142,7 +146,10 @@ def test_include_multiple_posts_and_linked
142146
}
143147
}]
144148
}
145-
assert_equal expected, @adapter.serializable_hash[:linked]
149+
hash = @adapter.serializable_hash
150+
151+
assert_equal :bios, hash.first[0]
152+
assert_equal expected, hash[:linked]
146153
end
147154

148155
def test_ignore_model_namespace_for_linked_resource_type

test/fixtures/poro.rb

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class ProfilePreviewSerializer < ActiveModel::Serializer
5454
end
5555

5656
Post = Class.new(Model)
57+
Like = Class.new(Model)
5758
Comment = Class.new(Model)
5859
Author = Class.new(Model)
5960
Bio = Class.new(Model)
@@ -103,13 +104,22 @@ def self.root_name
103104
end
104105

105106
RoleSerializer = Class.new(ActiveModel::Serializer) do
106-
attributes :id, :name
107+
cache only: [:name]
108+
attributes :id, :name, :description
107109

108110
belongs_to :author
109111
end
110112

113+
LikeSerializer = Class.new(ActiveModel::Serializer) do
114+
cache only: [:post]
115+
attributes :id, :time
116+
117+
belongs_to :post
118+
end
119+
111120
BioSerializer = Class.new(ActiveModel::Serializer) do
112-
attributes :id, :content
121+
cache except: [:content]
122+
attributes :id, :content, :rating
113123

114124
belongs_to :author
115125
end

0 commit comments

Comments
 (0)