From 8a432ad2b3a11b54183630b08b47ad2d249f9327 Mon Sep 17 00:00:00 2001 From: Joao Moura Date: Mon, 20 Oct 2014 21:38:20 -0200 Subject: [PATCH] Adding cache support to version 0.10.0 It's a new implementation of cache based on ActiveSupport::Cache. The implementation abstracts the cache in Adapter class on a private method called cached_object, this method is intended to be used on Adapters inside serializable_hash method in order to cache each instance of the object that will be returned by the serializer. Some of its features are: - A different syntax. (no longer need the cache_key method). - An options argument that have the same arguments of ActiveSupport::Cache::Store, plus a key option that will be the prefix of the object cache on a pattern "#{key}-#{object.id}". - It cache the objects individually and not the whole Serializer return, re-using it in different requests (as a show and a index method for example.) --- .gitignore | 2 + README.md | 32 +++++++ lib/active_model/serializer.rb | 12 ++- lib/active_model/serializer/adapter.rb | 14 +++ lib/active_model/serializer/adapter/json.rb | 22 +++-- .../serializer/adapter/json_api.rb | 8 +- lib/active_model_serializers.rb | 8 +- .../action_controller/json_api_linked_test.rb | 1 + test/action_controller/serialization_test.rb | 96 ++++++++++++++++++- test/adapter/json/belongs_to_test.rb | 1 + test/adapter/json/collection_test.rb | 1 + test/adapter/json_api/belongs_to_test.rb | 1 + test/adapter/json_api/collection_test.rb | 1 + test/adapter/json_api/has_many_test.rb | 1 + test/fixtures/poro.rb | 13 ++- test/serializers/cache_test.rb | 62 ++++++++++++ test/test_helper.rb | 12 ++- 17 files changed, 265 insertions(+), 22 deletions(-) create mode 100644 test/serializers/cache_test.rb diff --git a/.gitignore b/.gitignore index 1ecf6e4d1..0374e060e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ coverage doc/ lib/bundler/man pkg +Vagrantfile +.vagrant rdoc spec/reports test/tmp diff --git a/README.md b/README.md index c5fee72c1..e6a560fd9 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ serializers: ```ruby class PostSerializer < ActiveModel::Serializer + cache key: 'posts', expires_in: 3.hours attributes :title, :body has_many :comments @@ -246,6 +247,37 @@ You may also use the `:serializer` option to specify a custom serializer class, The `url` declaration describes which named routes to use while generating URLs for your JSON. Not every adapter will require URLs. +## Caching + +To cache a serializer, call ```cache``` and pass its options. +The options are the same options of ```ActiveSupport::Cache::Store```, plus +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 +cache(options = nil) # options: ```{key, expires_in, compress, force, race_condition_ttl}``` +``` + +Take the example bellow: + +```ruby +class PostSerializer < ActiveModel::Serializer + cache key: 'post', expires_in: 3.hours + attributes :title, :body + + has_many :comments + + url :post +end +``` + +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. + ## 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 e5d361a78..b083e40d2 100644 --- a/lib/active_model/serializer.rb +++ b/lib/active_model/serializer.rb @@ -10,6 +10,9 @@ class << self attr_accessor :_attributes attr_accessor :_associations attr_accessor :_urls + attr_accessor :_cache + attr_accessor :_cache_key + attr_accessor :_cache_options end def self.inherited(base) @@ -36,7 +39,14 @@ def self.attribute(attr, options = {}) end unless method_defined?(key) end - # Defines an association in the object that should be rendered. + # Enables a serializer to be automatically cached + def self.cache(options = {}) + @_cache = ActionController::Base.cache_store if Rails.configuration.action_controller.perform_caching + @_cache_key = options.delete(:key) + @_cache_options = (options.empty?) ? nil : options + end + + # Defines an association in the object should be rendered. # # The serializer object should implement the association name # as a method which should return an array when invoked. If a method diff --git a/lib/active_model/serializer/adapter.rb b/lib/active_model/serializer/adapter.rb index b1efdae68..85b014639 100644 --- a/lib/active_model/serializer/adapter.rb +++ b/lib/active_model/serializer/adapter.rb @@ -50,6 +50,20 @@ 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 8ad1e41d6..8848f8fbf 100644 --- a/lib/active_model/serializer/adapter/json.rb +++ b/lib/active_model/serializer/adapter/json.rb @@ -6,19 +6,21 @@ def serializable_hash(options = {}) if serializer.respond_to?(:each) @result = serializer.map{|s| self.class.new(s).serializable_hash } else - @result = serializer.attributes(options) - - serializer.each_association do |name, association, opts| - if association.respond_to?(:each) - array_serializer = association - @result[name] = array_serializer.map { |item| item.attributes(opts) } - else - if association - @result[name] = association.attributes(options) + @result = cached_object do + @hash = serializer.attributes(options) + serializer.each_association do |name, association, opts| + if association.respond_to?(:each) + array_serializer = association + @hash[name] = array_serializer.map { |item| item.attributes(opts) } else - @result[name] = nil + if association + @hash[name] = association.attributes(options) + else + @hash[name] = nil + end end end + @hash end end diff --git a/lib/active_model/serializer/adapter/json_api.rb b/lib/active_model/serializer/adapter/json_api.rb index dd84e6338..a88873690 100644 --- a/lib/active_model/serializer/adapter/json_api.rb +++ b/lib/active_model/serializer/adapter/json_api.rb @@ -23,10 +23,12 @@ def serializable_hash(options = {}) self.class.new(s, @options.merge(top: @top, fieldset: @fieldset)).serializable_hash[@root] end else - @hash[@root] = attributes_for_serializer(serializer, @options) - add_resource_links(@hash[@root], serializer) + @hash = cached_object do + @hash[@root] = attributes_for_serializer(serializer, @options) + add_resource_links(@hash[@root], serializer) + @hash + end end - @hash end diff --git a/lib/active_model_serializers.rb b/lib/active_model_serializers.rb index 5b0ed1b13..f566280ee 100644 --- a/lib/active_model_serializers.rb +++ b/lib/active_model_serializers.rb @@ -1,7 +1,7 @@ -require "active_model" -require "active_model/serializer/version" -require "active_model/serializer" -require "active_model/serializer/fieldset" +require 'active_model' +require 'active_model/serializer/version' +require 'active_model/serializer' +require 'active_model/serializer/fieldset' begin require 'action_controller' diff --git a/test/action_controller/json_api_linked_test.rb b/test/action_controller/json_api_linked_test.rb index 80f50ca5a..f909641db 100644 --- a/test/action_controller/json_api_linked_test.rb +++ b/test/action_controller/json_api_linked_test.rb @@ -5,6 +5,7 @@ module Serialization class JsonApiLinkedTest < ActionController::TestCase class MyController < ActionController::Base def setup_post + ActionController::Base.cache_store.clear @role1 = Role.new(id: 1, name: 'admin') @role2 = Role.new(id: 2, name: 'colab') @author = Author.new(id: 1, name: 'Steve K.') diff --git a/test/action_controller/serialization_test.rb b/test/action_controller/serialization_test.rb index 55ebf2367..0aeb895ae 100644 --- a/test/action_controller/serialization_test.rb +++ b/test/action_controller/serialization_test.rb @@ -46,9 +46,48 @@ def render_array_using_implicit_serializer_and_meta Profile.new({ name: 'Name 1', description: 'Description 1', comments: 'Comments 1' }) ] render json: array, meta: { total: 10 } - ensure + ensure ActiveModel::Serializer.config.adapter = old_adapter end + + def render_object_with_cache_enabled + 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 }) + + generate_cached_serializer(post) + + post.title = 'ZOMG a New Post' + render json: post + end + + def render_object_expired_with_cache_enabled + 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 }) + + generate_cached_serializer(post) + + post.title = 'ZOMG a New Post' + sleep 0.05 + render json: post + end + + def render_changed_object_with_cache_enabled + comment = Comment.new({ id: 1, body: 'ZOMG A COMMENT' }) + author = Author.new(id: 1, name: 'Joao Moura.') + post = Post.new({ id: 1, title: 'ZOMG a New Post', blog:nil, body: 'Body', comments: [comment], author: author }) + + render json: post + end + + private + def generate_cached_serializer(obj) + serializer_class = ActiveModel::Serializer.serializer_for(obj) + serializer = serializer_class.new(obj) + adapter = ActiveModel::Serializer.adapter.new(serializer) + adapter.to_json + end end tests MyController @@ -106,6 +145,61 @@ def test_render_array_using_implicit_serializer_and_meta assert_equal 'application/json', @response.content_type assert_equal '{"profiles":[{"name":"Name 1","description":"Description 1"}],"meta":{"total":10}}', @response.body end + + def test_render_with_cache_enable + ActionController::Base.cache_store.clear + get :render_object_with_cache_enabled + + expected = { + id: 1, + title: 'New Post', + body: 'Body', + comments: [ + { + id: 1, + body: 'ZOMG A COMMENT' } + ], + blog: nil, + author: { + id: 1, + name: 'Joao Moura.' + } + } + + assert_equal 'application/json', @response.content_type + assert_equal expected.to_json, @response.body + + get :render_changed_object_with_cache_enabled + assert_equal expected.to_json, @response.body + + ActionController::Base.cache_store.clear + get :render_changed_object_with_cache_enabled + assert_not_equal expected.to_json, @response.body + end + + def test_render_with_cache_enable_and_expired + ActionController::Base.cache_store.clear + get :render_object_expired_with_cache_enabled + + expected = { + id: 1, + title: 'ZOMG a New Post', + body: 'Body', + comments: [ + { + id: 1, + body: 'ZOMG A COMMENT' } + ], + blog: nil, + author: { + id: 1, + name: 'Joao Moura.' + } + } + + assert_equal 'application/json', @response.content_type + assert_equal expected.to_json, @response.body + end end end end diff --git a/test/adapter/json/belongs_to_test.rb b/test/adapter/json/belongs_to_test.rb index bb983f728..fb73779d9 100644 --- a/test/adapter/json/belongs_to_test.rb +++ b/test/adapter/json/belongs_to_test.rb @@ -21,6 +21,7 @@ def setup @serializer = CommentSerializer.new(@comment) @adapter = ActiveModel::Serializer::Adapter::Json.new(@serializer) + ActionController::Base.cache_store.clear end def test_includes_post diff --git a/test/adapter/json/collection_test.rb b/test/adapter/json/collection_test.rb index 0742333ad..77c43e290 100644 --- a/test/adapter/json/collection_test.rb +++ b/test/adapter/json/collection_test.rb @@ -19,6 +19,7 @@ def setup @serializer = ArraySerializer.new([@first_post, @second_post]) @adapter = ActiveModel::Serializer::Adapter::Json.new(@serializer) + ActionController::Base.cache_store.clear end def test_include_multiple_posts diff --git a/test/adapter/json_api/belongs_to_test.rb b/test/adapter/json_api/belongs_to_test.rb index 678e6a245..77dc3b5af 100644 --- a/test/adapter/json_api/belongs_to_test.rb +++ b/test/adapter/json_api/belongs_to_test.rb @@ -28,6 +28,7 @@ def setup @serializer = CommentSerializer.new(@comment) @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer) + ActionController::Base.cache_store.clear end def test_includes_post_id diff --git a/test/adapter/json_api/collection_test.rb b/test/adapter/json_api/collection_test.rb index 5ab06cfe4..2054af838 100644 --- a/test/adapter/json_api/collection_test.rb +++ b/test/adapter/json_api/collection_test.rb @@ -21,6 +21,7 @@ def setup @serializer = ArraySerializer.new([@first_post, @second_post]) @adapter = ActiveModel::Serializer::Adapter::JsonApi.new(@serializer) + ActionController::Base.cache_store.clear end def test_include_multiple_posts diff --git a/test/adapter/json_api/has_many_test.rb b/test/adapter/json_api/has_many_test.rb index b49288828..87ef36f3f 100644 --- a/test/adapter/json_api/has_many_test.rb +++ b/test/adapter/json_api/has_many_test.rb @@ -6,6 +6,7 @@ class Adapter class JsonApi class HasManyTest < Minitest::Test def setup + ActionController::Base.cache_store.clear @author = Author.new(id: 1, name: 'Steve K.') @author.posts = [] @author.bio = nil diff --git a/test/fixtures/poro.rb b/test/fixtures/poro.rb index c784d479e..65786dec6 100644 --- a/test/fixtures/poro.rb +++ b/test/fixtures/poro.rb @@ -3,6 +3,14 @@ def initialize(hash={}) @attributes = hash end + def cache_key + "#{self.class.name.downcase}/#{self.id}-#{self.updated_at}" + end + + def updated_at + @attributes[:updated_at] ||= DateTime.now.to_time.to_i + end + def read_attribute_for_serialization(name) if name == :id || name == 'id' id @@ -55,7 +63,8 @@ module Spam; end Spam::UnrelatedLink = Class.new(Model) PostSerializer = Class.new(ActiveModel::Serializer) do - attributes :title, :body, :id + cache key:'post', expires_in: 0.05 + attributes :id, :title, :body has_many :comments belongs_to :blog @@ -77,6 +86,7 @@ def self.root_name end CommentSerializer = Class.new(ActiveModel::Serializer) do + cache expires_in: 1.day attributes :id, :body belongs_to :post @@ -84,6 +94,7 @@ def self.root_name end AuthorSerializer = Class.new(ActiveModel::Serializer) do + cache key:'writer' attributes :id, :name has_many :posts, embed: :ids diff --git a/test/serializers/cache_test.rb b/test/serializers/cache_test.rb new file mode 100644 index 000000000..6377fa950 --- /dev/null +++ b/test/serializers/cache_test.rb @@ -0,0 +1,62 @@ +require 'test_helper' +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' }) + @author = Author.new(name: 'Joao M. D. Moura') + @role = Role.new(name: 'Great Author') + @author.posts = [@post] + @author.roles = [@role] + @author.bio = nil + @post.comments = [@comment] + @post.author = @author + @comment.post = @post + @comment.author = @author + + @post_serializer = PostSerializer.new(@post) + @author_serializer = AuthorSerializer.new(@author) + @comment_serializer = CommentSerializer.new(@comment) + end + + def test_cache_definition + assert_equal(ActionController::Base.cache_store, @post_serializer.class._cache) + assert_equal(ActionController::Base.cache_store, @author_serializer.class._cache) + assert_equal(ActionController::Base.cache_store, @comment_serializer.class._cache) + end + + def test_cache_key_definition + assert_equal('post', @post_serializer.class._cache_key) + assert_equal('writer', @author_serializer.class._cache_key) + assert_equal(nil, @comment_serializer.class._cache_key) + end + + def test_cache_key_interpolation_with_updated_at + author = render_object_with_cache_without_cache_key(@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) + 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(nil, @author_serializer.class._cache_options) + assert_equal({expires_in: 1.day}, @comment_serializer.class._cache_options) + end + + private + def render_object_with_cache_without_cache_key(obj) + serializer_class = ActiveModel::Serializer.serializer_for(obj) + serializer = serializer_class.new(obj) + adapter = ActiveModel::Serializer.adapter.new(serializer) + adapter.to_json + end + end + end +end + diff --git a/test/test_helper.rb b/test/test_helper.rb index bd41fb6af..f3977b610 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,13 +1,21 @@ -require "bundler/setup" +require 'bundler/setup' require 'rails' require 'action_controller' require 'action_controller/test_case' -require "active_support/json" +require 'action_controller/railtie' +require 'active_support/json' require 'minitest/autorun' # Ensure backward compatibility with Minitest 4 Minitest::Test = MiniTest::Unit::TestCase unless defined?(Minitest::Test) +class Foo < Rails::Application + if Rails.version.to_s.start_with? '4' + config.action_controller.perform_caching = true + ActionController::Base.cache_store = :memory_store + end +end + require "active_model_serializers" require 'fixtures/poro'