diff --git a/README.md b/README.md index 2f1cc4ec3..8270bcc80 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,7 @@ a ```key``` option that will be the prefix of the object cache on a pattern ```"#{key}/#{object.id}-#{object.updated_at}"```. **[NOTE] Every object is individually cached.** + **[NOTE] The cache is automatically expired after update an object but it's not deleted.** ```ruby @@ -294,6 +295,25 @@ On this example every ```Post``` object will be cached with the key ```"post/#{post.id}-#{post.updated_at}"```. You can use this key to expire it as you want, but in this case it will be automatically expired after 3 hours. +### Fragmenting Caching + +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. + +You can define the attribute or relationships by using ```only``` or ```except``` option on cache method. + +Example: + +```ruby +class PostSerializer < ActiveModel::Serializer + cache key: 'post', expires_in: 3.hours, only: [:title] + attributes :title, :body + + has_many :comments + + url :post +end +``` + ## Getting Help If you find a bug, please report an [Issue](https://github.com/rails-api/active_model_serializers/issues/new). diff --git a/lib/active_model/serializer.rb b/lib/active_model/serializer.rb index b083e40d2..3b974a7de 100644 --- a/lib/active_model/serializer.rb +++ b/lib/active_model/serializer.rb @@ -8,20 +8,25 @@ class Serializer class << self attr_accessor :_attributes + attr_accessor :_attributes_keys attr_accessor :_associations attr_accessor :_urls attr_accessor :_cache attr_accessor :_cache_key + attr_accessor :_cache_only + attr_accessor :_cache_except attr_accessor :_cache_options end def self.inherited(base) base._attributes = [] + base._attributes_keys = {} base._associations = {} base._urls = [] end def self.attributes(*attrs) + attrs = attrs.first if attrs.first.class == Array @_attributes.concat attrs attrs.each do |attr| @@ -33,6 +38,7 @@ def self.attributes(*attrs) def self.attribute(attr, options = {}) key = options.fetch(:key, attr) + @_attributes_keys[attr] = {key: key} if key != attr @_attributes.concat [key] define_method key do object.read_attribute_for_serialization(attr) @@ -43,6 +49,8 @@ def self.attribute(attr, options = {}) def self.cache(options = {}) @_cache = ActionController::Base.cache_store if Rails.configuration.action_controller.perform_caching @_cache_key = options.delete(:key) + @_cache_only = options.delete(:only) + @_cache_except = options.delete(:except) @_cache_options = (options.empty?) ? nil : options end diff --git a/lib/active_model/serializer/adapter.rb b/lib/active_model/serializer/adapter.rb index 85b014639..0cd1f49c8 100644 --- a/lib/active_model/serializer/adapter.rb +++ b/lib/active_model/serializer/adapter.rb @@ -1,3 +1,5 @@ +require 'active_model/serializer/fragment_cache' + module ActiveModel class Serializer class Adapter @@ -11,6 +13,7 @@ class Adapter def initialize(serializer, options = {}) @serializer = serializer @options = options + @klass = serializer.class end def serializable_hash(options = {}) @@ -34,6 +37,30 @@ def self.adapter_class(adapter) private + def cache_check + if is_cached? + @klass._cache.fetch(cache_key, @klass._cache_options) do + yield + end + elsif is_fragment_cached? + FragmentCache.new(self, serializer, @options, @root).fetch + else + yield + end + end + + def is_cached? + @klass._cache && !@klass._cache_only && !@klass._cache_except + end + + def is_fragment_cached? + @klass._cache_only && !@klass._cache_except || !@klass._cache_only && @klass._cache_except + end + + def cache_key + (@klass._cache_key) ? "#{@klass._cache_key}/#{serializer.object.id}-#{serializer.object.updated_at}" : serializer.object.cache_key + end + def meta serializer.meta if serializer.respond_to?(:meta) end @@ -50,20 +77,6 @@ def include_meta(json) json[meta_key] = meta if meta && root json end - - private - - def cached_object - klass = serializer.class - if klass._cache - _cache_key = (klass._cache_key) ? "#{klass._cache_key}/#{serializer.object.id}-#{serializer.object.updated_at}" : serializer.object.cache_key - klass._cache.fetch(_cache_key, klass._cache_options) do - yield - end - else - yield - end - end end end end diff --git a/lib/active_model/serializer/adapter/json.rb b/lib/active_model/serializer/adapter/json.rb index 8848f8fbf..014bd1d7d 100644 --- a/lib/active_model/serializer/adapter/json.rb +++ b/lib/active_model/serializer/adapter/json.rb @@ -6,7 +6,7 @@ def serializable_hash(options = {}) if serializer.respond_to?(:each) @result = serializer.map{|s| self.class.new(s).serializable_hash } else - @result = cached_object do + @result = cache_check do @hash = serializer.attributes(options) serializer.each_association do |name, association, opts| if association.respond_to?(:each) diff --git a/lib/active_model/serializer/adapter/json_api.rb b/lib/active_model/serializer/adapter/json_api.rb index a88873690..6cabfbeae 100644 --- a/lib/active_model/serializer/adapter/json_api.rb +++ b/lib/active_model/serializer/adapter/json_api.rb @@ -23,7 +23,7 @@ def serializable_hash(options = {}) self.class.new(s, @options.merge(top: @top, fieldset: @fieldset)).serializable_hash[@root] end else - @hash = cached_object do + @hash = cache_check do @hash[@root] = attributes_for_serializer(serializer, @options) add_resource_links(@hash[@root], serializer) @hash @@ -91,7 +91,6 @@ def add_linked(resource_name, serializers, parent = nil) end end - def attributes_for_serializer(serializer, options) if serializer.respond_to?(:each) result = [] diff --git a/lib/active_model/serializer/fragment_cache.rb b/lib/active_model/serializer/fragment_cache.rb new file mode 100644 index 000000000..1c9eaf41c --- /dev/null +++ b/lib/active_model/serializer/fragment_cache.rb @@ -0,0 +1,99 @@ +module ActiveModel + class Serializer + class FragmentCache + + attr_reader :serializer + + def initialize(adapter, serializer, options, root) + @root = root + @options = options + @adapter = adapter + @serializer = serializer + end + + def fetch + klass = serializer.class + serializers = fragment_serializer(@serializer.object.class.name, klass) + + cached_serializer = serializers[:cached].constantize.new(@serializer.object) + non_cached_serializer = serializers[:non_cached].constantize.new(@serializer.object) + + cached_hash = @adapter.class.new(cached_serializer, @options).serializable_hash + non_cached_hash = @adapter.class.new(non_cached_serializer, @options).serializable_hash + + if @serializer.root && @adapter.class == ActiveModel::Serializer::Adapter::JsonApi + cached_hash_root(cached_hash, non_cached_hash) + else + non_cached_hash.merge cached_hash + end + end + + private + + def cached_hash_root(cached_hash, non_cached_hash) + hash = {} + + core_cached = cached_hash.first + core_non_cached = non_cached_hash.first + no_root_cache = cached_hash.delete_if {|key, value| key == core_cached[0] } + no_root_non_cache = non_cached_hash.delete_if {|key, value| key == core_non_cached[0] } + + if @root + hash[@root] = (core_cached[1]) ? core_cached[1].merge(core_non_cached[1]) : core_non_cached[1] + else + hash = (core_cached[1]) ? core_cached[1].merge(core_non_cached[1]) : core_non_cached[1] + end + hash.merge no_root_non_cache.merge no_root_cache + end + + def fragment_associations(serializers, associations) + associations.each do |association| + options = ",#{association[1][:association_options]}" if association[1].include?(:association_options) + eval("#{serializers[:non_cached]}.#{association[1][:type].to_s}(:#{association[0]}#{options})") + end + end + + def cached_attributes_and_association(klass, serializers) + cached_attr = (klass._cache_only) ? klass._cache_only : @serializer.attributes.keys.delete_if {|attr| klass._cache_except.include?(attr) } + non_cached_attr = @serializer.attributes.keys.delete_if {|attr| cached_attr.include?(attr) } + associations = @serializer.each_association + + cached_attr.each do |attr| + if @serializer.each_association.keys.include?(attr) + associations.delete(attr) + serializers[:cached].constantize.send(@serializer.each_association[attr][:type], attr) + end + end + + cached_attr.each do |attribute| + options = @serializer.class._attributes_keys[attribute] + options ||= {} + serializers[:cached].constantize.attribute(attribute, options) + end + + non_cached_attr.each do |attribute| + options = @serializer.class._attributes_keys[attribute] + options ||= {} + serializers[:non_cached].constantize.attribute(attribute, options) + end + fragment_associations(serializers, associations) + end + + def fragment_serializer(name, klass) + cached = "#{name.capitalize}CachedSerializer" + non_cached = "#{name.capitalize}NonCachedSerializer" + + Object.const_set cached, Class.new(ActiveModel::Serializer) unless Object.const_defined?(cached) + Object.const_set non_cached, Class.new(ActiveModel::Serializer) unless Object.const_defined?(non_cached) + + klass._cache_options ||= {} + klass._cache_options[:key] = klass._cache_key if klass._cache_key + cached.constantize.cache(klass._cache_options) + + serializers = {cached: cached, non_cached: non_cached} + cached_attributes_and_association(klass, serializers) + return serializers + end + end + end +end \ No newline at end of file diff --git a/test/action_controller/json_api_linked_test.rb b/test/action_controller/json_api_linked_test.rb index f909641db..672834576 100644 --- a/test/action_controller/json_api_linked_test.rb +++ b/test/action_controller/json_api_linked_test.rb @@ -110,12 +110,14 @@ def test_render_resource_with_nested_has_many_include "roles"=>[{ "id" => "1", "name" => "admin", + "description" => nil, "links" => { "author" => "1" } }, { "id" => "2", "name" => "colab", + "description" => nil, "links" => { "author" => "1" } diff --git a/test/action_controller/serialization_test.rb b/test/action_controller/serialization_test.rb index 0aeb895ae..b1976791f 100644 --- a/test/action_controller/serialization_test.rb +++ b/test/action_controller/serialization_test.rb @@ -69,7 +69,7 @@ def render_object_expired_with_cache_enabled generate_cached_serializer(post) post.title = 'ZOMG a New Post' - sleep 0.05 + sleep 0.1 render json: post end @@ -81,6 +81,42 @@ def render_changed_object_with_cache_enabled render json: post end + def render_fragment_changed_object_with_only_cache_enabled + author = Author.new(id: 1, name: 'Joao Moura.') + role = Role.new({ id: 42, name: 'ZOMG A ROLE', description: 'DESCRIPTION HERE', author: author }) + + generate_cached_serializer(role) + role.name = 'lol' + role.description = 'HUEHUEBRBR' + + render json: role + end + + def render_fragment_changed_object_with_except_cache_enabled + author = Author.new(id: 1, name: 'Joao Moura.') + bio = Bio.new({ id: 42, content: 'ZOMG A ROLE', rating: 5, author: author }) + + generate_cached_serializer(bio) + bio.content = 'lol' + bio.rating = 0 + + render json: bio + end + + def render_fragment_changed_object_with_relationship + comment = Comment.new({ id: 1, body: 'ZOMG A COMMENT' }) + author = Author.new(id: 1, name: 'Joao Moura.') + post = Post.new({ id: 1, title: 'New Post', blog:nil, body: 'Body', comments: [comment], author: author }) + post2 = Post.new({ id: 1, title: 'New Post2', blog:nil, body: 'Body2', comments: [comment], author: author }) + like = Like.new({ id: 1, post: post, time: 3.days.ago }) + + generate_cached_serializer(like) + like.post = post2 + like.time = DateTime.now.to_s + + render json: like + end + private def generate_cached_serializer(obj) serializer_class = ActiveModel::Serializer.serializer_for(obj) @@ -200,6 +236,45 @@ def test_render_with_cache_enable_and_expired assert_equal 'application/json', @response.content_type assert_equal expected.to_json, @response.body end + + def test_render_with_fragment_only_cache_enable + ActionController::Base.cache_store.clear + get :render_fragment_changed_object_with_only_cache_enabled + response = JSON.parse(@response.body) + + assert_equal 'application/json', @response.content_type + assert_equal 'ZOMG A ROLE', response["name"] + assert_equal 'HUEHUEBRBR', response["description"] + end + + def test_render_with_fragment_except_cache_enable + ActionController::Base.cache_store.clear + get :render_fragment_changed_object_with_except_cache_enabled + response = JSON.parse(@response.body) + + assert_equal 'application/json', @response.content_type + assert_equal 5, response["rating"] + assert_equal 'lol', response["content"] + end + + def test_render_fragment_changed_object_with_relationship + ActionController::Base.cache_store.clear + get :render_fragment_changed_object_with_relationship + response = JSON.parse(@response.body) + + expected_return = { + "post" => { + "id"=>1, + "title"=>"New Post", + "body"=>"Body" + }, + "id"=>1, + "time"=>DateTime.now.to_s + } + + assert_equal 'application/json', @response.content_type + assert_equal expected_return, response + end end end -end +end \ No newline at end of file diff --git a/test/adapter/json_api/linked_test.rb b/test/adapter/json_api/linked_test.rb index 50425325e..f08fb9224 100644 --- a/test/adapter/json_api/linked_test.rb +++ b/test/adapter/json_api/linked_test.rb @@ -6,6 +6,7 @@ class Adapter class JsonApi class LinkedTest < Minitest::Test def setup + ActionController::Base.cache_store.clear @author1 = Author.new(id: 1, name: 'Steve K.') @author2 = Author.new(id: 2, name: 'Tenderlove') @bio1 = Bio.new(id: 1, content: 'AMS Contributor') @@ -32,7 +33,7 @@ def setup @bio2.author = @author2 end - def test_include_multiple_posts_and_linked + def test_include_multiple_posts_and_linked_serializer @serializer = ArraySerializer.new([@first_post, @second_post]) @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'author,author.bio,comments') @@ -44,8 +45,8 @@ def test_include_multiple_posts_and_linked @second_comment.post = @first_post @second_comment.author = nil assert_equal([ - { title: "Hello!!", body: "Hello, world!!", id: "1", links: { comments: ['1', '2'], author: "1" } }, - { title: "New Post", body: "Body", id: "2", links: { comments: [], :author => "2" } } + { :id=>"1", :title=>"Hello!!", :body=>"Hello, world!!", :links=>{:comments=>["1", "2"], :blog=>"999", :author=>"1"} }, + { :id=>"2", :title=>"New Post", :body=>"Body", :links=>{:comments=>[], :blog=>"999", :author=>"2"} } ], @adapter.serializable_hash[:posts]) @@ -69,7 +70,7 @@ def test_include_multiple_posts_and_linked id: "1", name: "Steve K.", links: { - posts: ["1"], + posts: ["1", "3"], roles: [], bio: "1" } @@ -85,12 +86,14 @@ def test_include_multiple_posts_and_linked bios: [{ id: "1", content: "AMS Contributor", + rating: nil, links: { author: "1" } }, { id: "2", content: "Rails Contributor", + rating: nil, links: { author: "2" } @@ -99,9 +102,9 @@ def test_include_multiple_posts_and_linked assert_equal expected, @adapter.serializable_hash[:linked] end - def test_include_multiple_posts_and_linked + def test_include_multiple_posts_and_linked_array_serializer @serializer = BioSerializer.new(@bio1) - @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'author,author.posts') + @serializer.class.config.adapter = :json_api @first_comment = Comment.new(id: 1, body: 'ZOMG A COMMENT') @second_comment = Comment.new(id: 2, body: 'ZOMG ANOTHER COMMENT') @@ -111,6 +114,7 @@ def test_include_multiple_posts_and_linked @first_comment.author = nil @second_comment.post = @first_post @second_comment.author = nil + @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer, include: 'author,author.posts') expected = { authors: [{ @@ -142,7 +146,10 @@ def test_include_multiple_posts_and_linked } }] } - assert_equal expected, @adapter.serializable_hash[:linked] + hash = @adapter.serializable_hash + + assert_equal :bios, hash.first[0] + assert_equal expected, hash[:linked] end def test_ignore_model_namespace_for_linked_resource_type diff --git a/test/fixtures/poro.rb b/test/fixtures/poro.rb index 65786dec6..dc9623f23 100644 --- a/test/fixtures/poro.rb +++ b/test/fixtures/poro.rb @@ -54,6 +54,7 @@ class ProfilePreviewSerializer < ActiveModel::Serializer end Post = Class.new(Model) +Like = Class.new(Model) Comment = Class.new(Model) Author = Class.new(Model) Bio = Class.new(Model) @@ -63,7 +64,7 @@ module Spam; end Spam::UnrelatedLink = Class.new(Model) PostSerializer = Class.new(ActiveModel::Serializer) do - cache key:'post', expires_in: 0.05 + cache key:'post', expires_in: 0.1 attributes :id, :title, :body has_many :comments @@ -103,13 +104,22 @@ def self.root_name end RoleSerializer = Class.new(ActiveModel::Serializer) do - attributes :id, :name + cache only: [:name] + attributes :id, :name, :description belongs_to :author end +LikeSerializer = Class.new(ActiveModel::Serializer) do + cache only: [:post] + attributes :id, :time + + belongs_to :post +end + BioSerializer = Class.new(ActiveModel::Serializer) do - attributes :id, :content + cache except: [:content] + attributes :id, :content, :rating belongs_to :author end diff --git a/test/serializers/cache_test.rb b/test/serializers/cache_test.rb index 6377fa950..bc7000458 100644 --- a/test/serializers/cache_test.rb +++ b/test/serializers/cache_test.rb @@ -3,18 +3,22 @@ module ActiveModel class Serializer class CacheTest < Minitest::Test def setup - @post = Post.new({ title: 'New Post', body: 'Body' }) - @comment = Comment.new({ id: 1, body: 'ZOMG A COMMENT' }) + @comment = Comment.new(id: 1, body: 'ZOMG A COMMENT') + @post = Post.new(title: 'New Post', body: 'Body') + @bio = Bio.new(id: 1, content: 'AMS Contributor') @author = Author.new(name: 'Joao M. D. Moura') @role = Role.new(name: 'Great Author') @author.posts = [@post] @author.roles = [@role] - @author.bio = nil + @role.author = @author + @author.bio = @bio @post.comments = [@comment] @post.author = @author @comment.post = @post @comment.author = @author + @bio_serializer = BioSerializer.new(@bio) + @role_serializer = RoleSerializer.new(@role) @post_serializer = PostSerializer.new(@post) @author_serializer = AuthorSerializer.new(@author) @comment_serializer = CommentSerializer.new(@comment) @@ -33,24 +37,29 @@ def test_cache_key_definition end def test_cache_key_interpolation_with_updated_at - author = render_object_with_cache_without_cache_key(@author) + author = render_object_with_cache(@author) assert_equal(nil, ActionController::Base.cache_store.fetch(@author.cache_key)) assert_equal(author, ActionController::Base.cache_store.fetch("#{@author_serializer.class._cache_key}/#{@author_serializer.object.id}-#{@author_serializer.object.updated_at}").to_json) end def test_default_cache_key_fallback - comment = render_object_with_cache_without_cache_key(@comment) + comment = render_object_with_cache(@comment) assert_equal(comment, ActionController::Base.cache_store.fetch(@comment.cache_key).to_json) end def test_cache_options_definition - assert_equal({expires_in: 0.05}, @post_serializer.class._cache_options) + assert_equal({expires_in: 0.1}, @post_serializer.class._cache_options) assert_equal(nil, @author_serializer.class._cache_options) assert_equal({expires_in: 1.day}, @comment_serializer.class._cache_options) end + def test_fragment_cache_definition + assert_equal([:name], @role_serializer.class._cache_only) + assert_equal([:content], @bio_serializer.class._cache_except) + end + private - def render_object_with_cache_without_cache_key(obj) + def render_object_with_cache(obj) serializer_class = ActiveModel::Serializer.serializer_for(obj) serializer = serializer_class.new(obj) adapter = ActiveModel::Serializer.adapter.new(serializer) diff --git a/test/serializers/fragment_cache_test.rb b/test/serializers/fragment_cache_test.rb new file mode 100644 index 000000000..5a55ff635 --- /dev/null +++ b/test/serializers/fragment_cache_test.rb @@ -0,0 +1,35 @@ +require 'test_helper' +module ActiveModel + class Serializer + class FragmentCacheTest < Minitest::Test + def setup + @author = Author.new(name: 'Joao M. D. Moura') + @role = Role.new(name: 'Great Author', description:nil) + @role.author = [@author] + @role_serializer = RoleSerializer.new(@role) + @fragment_cache = FragmentCache.new(RoleSerializer.adapter.new(@role_serializer), @role_serializer, {}, nil) + end + + def test_fragment_definition + assert_equal(@fragment_cache.serializer, @role_serializer) + assert_equal(@fragment_cache.fetch, @role_serializer) + end + + def test_fragment_definition + expected_result = { + id: @role.id, + description: @role.description, + author: [ + { + id: @author.id, + name: @author.name, + } + ], + name: @role.name + } + assert_equal(@fragment_cache.fetch, expected_result) + end + end + end +end +