diff --git a/.gitignore b/.gitignore index 1ecf6e4d..0374e060 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 c5fee72c..e6a560fd 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 e5d361a7..b083e40d 100644 --- a/lib/active_model/serializer.rb +++ b/lib/active_model/serializer.rb @@ -10,6 +10,9 @@ module ActiveModel 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 @@ module ActiveModel 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 b1efdae6..85b01463 100644 --- a/lib/active_model/serializer/adapter.rb +++ b/lib/active_model/serializer/adapter.rb @@ -50,6 +50,20 @@ module ActiveModel 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 8ad1e41d..8848f8fb 100644 --- a/lib/active_model/serializer/adapter/json.rb +++ b/lib/active_model/serializer/adapter/json.rb @@ -6,19 +6,21 @@ module ActiveModel 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 dd84e633..a8887369 100644 --- a/lib/active_model/serializer/adapter/json_api.rb +++ b/lib/active_model/serializer/adapter/json_api.rb @@ -23,10 +23,12 @@ module ActiveModel 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 5b0ed1b1..f566280e 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 80f50ca5..f909641d 100644 --- a/test/action_controller/json_api_linked_test.rb +++ b/test/action_controller/json_api_linked_test.rb @@ -5,6 +5,7 @@ module ActionController 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 55ebf236..0aeb895a 100644 --- a/test/action_controller/serialization_test.rb +++ b/test/action_controller/serialization_test.rb @@ -46,9 +46,48 @@ module ActionController 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 @@ module ActionController 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 bb983f72..fb73779d 100644 --- a/test/adapter/json/belongs_to_test.rb +++ b/test/adapter/json/belongs_to_test.rb @@ -21,6 +21,7 @@ module ActiveModel @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 0742333a..77c43e29 100644 --- a/test/adapter/json/collection_test.rb +++ b/test/adapter/json/collection_test.rb @@ -19,6 +19,7 @@ module ActiveModel @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 678e6a24..77dc3b5a 100644 --- a/test/adapter/json_api/belongs_to_test.rb +++ b/test/adapter/json_api/belongs_to_test.rb @@ -28,6 +28,7 @@ module ActiveModel @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 5ab06cfe..2054af83 100644 --- a/test/adapter/json_api/collection_test.rb +++ b/test/adapter/json_api/collection_test.rb @@ -21,6 +21,7 @@ module ActiveModel @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 b4928882..87ef36f3 100644 --- a/test/adapter/json_api/has_many_test.rb +++ b/test/adapter/json_api/has_many_test.rb @@ -6,6 +6,7 @@ module ActiveModel 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 c784d479..65786dec 100644 --- a/test/fixtures/poro.rb +++ b/test/fixtures/poro.rb @@ -3,6 +3,14 @@ class Model @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 @@ SpammyPostSerializer = Class.new(ActiveModel::Serializer) do end CommentSerializer = Class.new(ActiveModel::Serializer) do + cache expires_in: 1.day attributes :id, :body belongs_to :post @@ -84,6 +94,7 @@ CommentSerializer = Class.new(ActiveModel::Serializer) do 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 00000000..6377fa95 --- /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 bd41fb6a..f3977b61 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'