Skip to content

Commit 3de7324

Browse files
committed
Adding Fragment Cache to AMS
It's an upgrade based on the new Cache implementation #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 3de7324

File tree

10 files changed

+250
-31
lines changed

10 files changed

+250
-31
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: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
require 'active_model/serializer/cache'
2+
13
module ActiveModel
24
class Serializer
35
class Adapter
46
extend ActiveSupport::Autoload
7+
include ActiveModel::Serializer::Cache
58
autoload :Json
69
autoload :Null
710
autoload :JsonApi
@@ -50,20 +53,6 @@ def include_meta(json)
5053
json[meta_key] = meta if meta && root
5154
json
5255
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
6756
end
6857
end
6958
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 = []

lib/active_model/serializer/cache.rb

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

0 commit comments

Comments
 (0)