From 049b6e670f0a9d7dc03ee4ddf9334ddbeb6c7900 Mon Sep 17 00:00:00 2001
From: Stan Lo <a22301613@yahoo.com.tw>
Date: Wed, 23 Dec 2015 16:01:32 +0800
Subject: Porting ActionController::Caching to ActionMailer::Caching

---
 actionmailer/lib/action_mailer.rb                  |   1 +
 actionmailer/lib/action_mailer/base.rb             |   1 +
 actionmailer/lib/action_mailer/caching.rb          |  73 +++++++
 .../lib/action_mailer/caching/fragments.rb         | 148 +++++++++++++
 actionmailer/lib/action_mailer/railtie.rb          |   1 +
 actionmailer/test/caching_test.rb                  | 232 +++++++++++++++++++++
 .../test/fixtures/caching_mailer/_partial.html.erb |   3 +
 .../caching_mailer/fragment_cache.html.erb         |   3 +
 .../fragment_cache_in_partials.html.erb            |   1 +
 .../skip_fragment_cache_digesting.html.erb         |   3 +
 actionmailer/test/mailers/caching_mailer.rb        |  15 ++
 11 files changed, 481 insertions(+)
 create mode 100644 actionmailer/lib/action_mailer/caching.rb
 create mode 100644 actionmailer/lib/action_mailer/caching/fragments.rb
 create mode 100644 actionmailer/test/caching_test.rb
 create mode 100644 actionmailer/test/fixtures/caching_mailer/_partial.html.erb
 create mode 100644 actionmailer/test/fixtures/caching_mailer/fragment_cache.html.erb
 create mode 100644 actionmailer/test/fixtures/caching_mailer/fragment_cache_in_partials.html.erb
 create mode 100644 actionmailer/test/fixtures/caching_mailer/skip_fragment_cache_digesting.html.erb
 create mode 100644 actionmailer/test/mailers/caching_mailer.rb

diff --git a/actionmailer/lib/action_mailer.rb b/actionmailer/lib/action_mailer.rb
index 55c017e338..26fbb48600 100644
--- a/actionmailer/lib/action_mailer.rb
+++ b/actionmailer/lib/action_mailer.rb
@@ -44,6 +44,7 @@ module ActionMailer
   autoload :MailHelper
   autoload :Preview
   autoload :Previews, 'action_mailer/preview'
+  autoload :Caching
   autoload :TestCase
   autoload :TestHelper
   autoload :MessageDelivery
diff --git a/actionmailer/lib/action_mailer/base.rb b/actionmailer/lib/action_mailer/base.rb
index 4259eb0bee..9b14a96d48 100644
--- a/actionmailer/lib/action_mailer/base.rb
+++ b/actionmailer/lib/action_mailer/base.rb
@@ -420,6 +420,7 @@ module ActionMailer
   class Base < AbstractController::Base
     include DeliveryMethods
     include Previews
+    include Caching
 
     abstract!
 
diff --git a/actionmailer/lib/action_mailer/caching.rb b/actionmailer/lib/action_mailer/caching.rb
new file mode 100644
index 0000000000..27da8148d1
--- /dev/null
+++ b/actionmailer/lib/action_mailer/caching.rb
@@ -0,0 +1,73 @@
+require 'active_support/descendants_tracker'
+
+module ActionMailer
+  module Caching
+    extend ActiveSupport::Concern
+    extend ActiveSupport::Autoload
+
+    eager_autoload do
+      autoload :Fragments
+    end
+
+    module ConfigMethods
+      def cache_store
+        config.cache_store
+      end
+
+      def cache_store=(store)
+        config.cache_store = ActiveSupport::Cache.lookup_store(store)
+      end
+
+      private
+        def cache_configured?
+          perform_caching && cache_store
+        end
+    end
+
+    include AbstractController::Helpers
+    include ConfigMethods
+    include Fragments
+
+    included do
+      extend ConfigMethods
+
+      config_accessor :default_static_extension
+      self.default_static_extension ||= '.html'
+
+      config_accessor :perform_caching
+      self.perform_caching = true if perform_caching.nil?
+
+      class_attribute :_view_cache_dependencies
+      self._view_cache_dependencies = []
+      helper_method :view_cache_dependencies if respond_to?(:helper_method)
+    end
+
+    module ClassMethods
+      def view_cache_dependency(&dependency)
+        self._view_cache_dependencies += [dependency]
+      end
+    end
+
+    def view_cache_dependencies
+      self.class._view_cache_dependencies.map { |dep| instance_exec(&dep) }.compact
+    end
+
+    protected
+      # Convenience accessor.
+    def cache(key, options = {}, &block)
+      if cache_configured?
+        cache_store.fetch(ActiveSupport::Cache.expand_cache_key(key, :controller), options, &block)
+      else
+        yield
+      end
+    end
+
+    def perform_caching
+      Base.perform_caching
+    end
+
+    def controller_name
+      "ActionMailer"
+    end
+  end
+end
diff --git a/actionmailer/lib/action_mailer/caching/fragments.rb b/actionmailer/lib/action_mailer/caching/fragments.rb
new file mode 100644
index 0000000000..6c70137626
--- /dev/null
+++ b/actionmailer/lib/action_mailer/caching/fragments.rb
@@ -0,0 +1,148 @@
+module ActionMailer
+  module Caching
+    # Fragment caching is used for caching various blocks within
+    # views without caching the entire action as a whole. This is
+    # useful when certain elements of an action change frequently or
+    # depend on complicated state while other parts rarely change or
+    # can be shared amongst multiple parties. The caching is done using
+    # the +cache+ helper available in the Action View. See
+    # ActionView::Helpers::CacheHelper for more information.
+    #
+    # While it's strongly recommended that you use key-based cache
+    # expiration (see links in CacheHelper for more information),
+    # it is also possible to manually expire caches. For example:
+    #
+    #   expire_fragment('name_of_cache')
+    module Fragments
+      extend ActiveSupport::Concern
+
+      included do
+        if respond_to?(:class_attribute)
+          class_attribute :fragment_cache_keys
+        else
+          mattr_writer :fragment_cache_keys
+        end
+
+        self.fragment_cache_keys = []
+
+        helper_method :fragment_cache_key if respond_to?(:helper_method)
+      end
+
+      module ClassMethods
+        # Allows you to specify controller-wide key prefixes for
+        # cache fragments. Pass either a constant +value+, or a block
+        # which computes a value each time a cache key is generated.
+        #
+        # For example, you may want to prefix all fragment cache keys
+        # with a global version identifier, so you can easily
+        # invalidate all caches.
+        #
+        #   class ApplicationController
+        #     fragment_cache_key "v1"
+        #   end
+        #
+        # When it's time to invalidate all fragments, simply change
+        # the string constant. Or, progressively roll out the cache
+        # invalidation using a computed value:
+        #
+        #   class ApplicationController
+        #     fragment_cache_key do
+        #       @account.id.odd? ? "v1" : "v2"
+        #     end
+        #   end
+        def fragment_cache_key(value = nil, &key)
+          self.fragment_cache_keys += [key || ->{ value }]
+        end
+      end
+
+      # Given a key (as described in +expire_fragment+), returns
+      # a key suitable for use in reading, writing, or expiring a
+      # cached fragment. All keys begin with <tt>views/</tt>,
+      # followed by any controller-wide key prefix values, ending
+      # with the specified +key+ value. The key is expanded using
+      # ActiveSupport::Cache.expand_cache_key.
+      def fragment_cache_key(key)
+        head = self.class.fragment_cache_keys.map { |k| instance_exec(&k) }
+        tail = key.is_a?(Hash) ? url_for(key).split("://").last : key
+        ActiveSupport::Cache.expand_cache_key([*head, *tail], :views)
+      end
+
+      # Writes +content+ to the location signified by
+      # +key+ (see +expire_fragment+ for acceptable formats).
+      def write_fragment(key, content, options = nil)
+        return content unless cache_configured?
+
+        key = fragment_cache_key(key)
+        instrument_fragment_cache :write_fragment, key do
+          content = content.to_str
+          cache_store.write(key, content, options)
+        end
+        content
+      end
+
+      # Reads a cached fragment from the location signified by +key+
+      # (see +expire_fragment+ for acceptable formats).
+      def read_fragment(key, options = nil)
+        return unless cache_configured?
+
+        key = fragment_cache_key(key)
+        instrument_fragment_cache :read_fragment, key do
+          result = cache_store.read(key, options)
+          result.respond_to?(:html_safe) ? result.html_safe : result
+        end
+      end
+
+      # Check if a cached fragment from the location signified by
+      # +key+ exists (see +expire_fragment+ for acceptable formats).
+      def fragment_exist?(key, options = nil)
+        return unless cache_configured?
+        key = fragment_cache_key(key)
+
+        instrument_fragment_cache :exist_fragment?, key do
+          cache_store.exist?(key, options)
+        end
+      end
+
+      # Removes fragments from the cache.
+      #
+      # +key+ can take one of three forms:
+      #
+      # * String - This would normally take the form of a path, like
+      #   <tt>pages/45/notes</tt>.
+      # * Hash - Treated as an implicit call to +url_for+, like
+      #   <tt>{ controller: 'pages', action: 'notes', id: 45}</tt>
+      # * Regexp - Will remove any fragment that matches, so
+      #   <tt>%r{pages/\d*/notes}</tt> might remove all notes. Make sure you
+      #   don't use anchors in the regex (<tt>^</tt> or <tt>$</tt>) because
+      #   the actual filename matched looks like
+      #   <tt>./cache/filename/path.cache</tt>. Note: Regexp expiration is
+      #   only supported on caches that can iterate over all keys (unlike
+      #   memcached).
+      #
+      # +options+ is passed through to the cache store's +delete+
+      # method (or <tt>delete_matched</tt>, for Regexp keys).
+      def expire_fragment(key, options = nil)
+        return unless cache_configured?
+        key = fragment_cache_key(key) unless key.is_a?(Regexp)
+
+        instrument_fragment_cache :expire_fragment, key do
+          if key.is_a?(Regexp)
+            cache_store.delete_matched(key, options)
+          else
+            cache_store.delete(key, options)
+          end
+        end
+      end
+
+      def instrument_fragment_cache(name, key) # :nodoc:
+        payload = {
+          controller: controller_name,
+          action: action_name,
+          key: key
+        }
+
+        ActiveSupport::Notifications.instrument("#{name}.action_controller", payload) { yield }
+      end
+    end
+  end
+end
diff --git a/actionmailer/lib/action_mailer/railtie.rb b/actionmailer/lib/action_mailer/railtie.rb
index ae89492b0f..fce0d0f9f1 100644
--- a/actionmailer/lib/action_mailer/railtie.rb
+++ b/actionmailer/lib/action_mailer/railtie.rb
@@ -25,6 +25,7 @@ module ActionMailer
       options.javascripts_dir ||= paths["public/javascripts"].first
       options.stylesheets_dir ||= paths["public/stylesheets"].first
       options.show_previews = Rails.env.development? if options.show_previews.nil?
+      options.perform_caching ||= true
 
       if options.show_previews
         options.preview_path  ||= defined?(Rails.root) ? "#{Rails.root}/test/mailers/previews" : nil
diff --git a/actionmailer/test/caching_test.rb b/actionmailer/test/caching_test.rb
new file mode 100644
index 0000000000..a92f43a6c5
--- /dev/null
+++ b/actionmailer/test/caching_test.rb
@@ -0,0 +1,232 @@
+require 'fileutils'
+require 'abstract_unit'
+require 'mailers/base_mailer'
+require 'mailers/caching_mailer'
+require 'byebug'
+
+CACHE_DIR = 'test_cache'
+# Don't change '/../temp/' cavalierly or you might hose something you don't want hosed
+FILE_STORE_PATH = File.join(File.dirname(__FILE__), '/../temp/', CACHE_DIR)
+
+class FragmentCachingMailer < ActionMailer::Base
+  abstract!
+
+  include ActionMailer::Caching
+
+  def some_action; end
+end
+
+class BaseCachingTest < ActiveSupport::TestCase
+  def setup
+    super
+    @store = ActiveSupport::Cache::MemoryStore.new
+    @mailer = FragmentCachingMailer.new
+    @mailer.perform_caching = true
+    @mailer.cache_store = @store
+  end
+
+  def test_fragment_cache_key
+    assert_equal 'views/what a key', @mailer.fragment_cache_key('what a key')
+  end
+end
+
+class FragmentCachingTest < BaseCachingTest
+  def test_read_fragment_with_caching_enabled
+    @store.write('views/name', 'value')
+    assert_equal 'value', @mailer.read_fragment('name')
+  end
+
+  def test_read_fragment_with_caching_disabled
+    @mailer.perform_caching = false
+    @store.write('views/name', 'value')
+    assert_nil @mailer.read_fragment('name')
+  end
+
+  def test_fragment_exist_with_caching_enabled
+    @store.write('views/name', 'value')
+    assert @mailer.fragment_exist?('name')
+    assert !@mailer.fragment_exist?('other_name')
+  end
+
+  def test_fragment_exist_with_caching_disabled
+    @mailer.perform_caching = false
+    @store.write('views/name', 'value')
+    assert !@mailer.fragment_exist?('name')
+    assert !@mailer.fragment_exist?('other_name')
+  end
+
+  def test_write_fragment_with_caching_enabled
+    assert_nil @store.read('views/name')
+    assert_equal 'value', @mailer.write_fragment('name', 'value')
+    assert_equal 'value', @store.read('views/name')
+  end
+
+  def test_write_fragment_with_caching_disabled
+    assert_nil @store.read('views/name')
+    @mailer.perform_caching = false
+    assert_equal 'value', @mailer.write_fragment('name', 'value')
+    assert_nil @store.read('views/name')
+  end
+
+  def test_expire_fragment_with_simple_key
+    @store.write('views/name', 'value')
+    @mailer.expire_fragment 'name'
+    assert_nil @store.read('views/name')
+  end
+
+  def test_expire_fragment_with_regexp
+    @store.write('views/name', 'value')
+    @store.write('views/another_name', 'another_value')
+    @store.write('views/primalgrasp', 'will not expire ;-)')
+
+    @mailer.expire_fragment(/name/)
+
+    assert_nil @store.read('views/name')
+    assert_nil @store.read('views/another_name')
+    assert_equal 'will not expire ;-)', @store.read('views/primalgrasp')
+  end
+
+  def test_fragment_for
+    @store.write('views/expensive', 'fragment content')
+    fragment_computed = false
+
+    view_context = @mailer.view_context
+
+    buffer = 'generated till now -> '.html_safe
+    buffer << view_context.send(:fragment_for, 'expensive') { fragment_computed = true }
+
+    assert !fragment_computed
+    assert_equal 'generated till now -> fragment content', buffer
+  end
+
+  def test_html_safety
+    assert_nil @store.read('views/name')
+    content = 'value'.html_safe
+    assert_equal content, @mailer.write_fragment('name', content)
+
+    cached = @store.read('views/name')
+    assert_equal content, cached
+    assert_equal String, cached.class
+
+    html_safe = @mailer.read_fragment('name')
+    assert_equal content, html_safe
+    assert html_safe.html_safe?
+  end
+end
+
+class FunctionalFragmentCachingTest < BaseCachingTest
+  def setup
+    super
+    @store = ActiveSupport::Cache::MemoryStore.new
+    @mailer = CachingMailer.new
+    @mailer.perform_caching = true
+    @mailer.cache_store = @store
+  end
+
+  def test_fragment_caching
+    email = @mailer.fragment_cache
+    expected_body = "\"Welcome\""
+
+    assert_match expected_body, email.body.encoded
+    assert_match "\"Welcome\"",
+      @store.read("views/caching/#{template_digest("caching_mailer/fragment_cache")}")
+  end
+
+  def test_fragment_caching_in_partials
+    email = @mailer.fragment_cache_in_partials
+    assert_match(/Old fragment caching in a partial/, email.body.encoded)
+
+    assert_match("Old fragment caching in a partial",
+      @store.read("views/caching/#{template_digest("caching_mailer/_partial")}"))
+  end
+
+  def test_skip_fragment_cache_digesting
+    email = @mailer.skip_fragment_cache_digesting
+    expected_body = "No Digest"
+
+    assert_match expected_body, email.body.encoded
+    assert_match expected_body, @store.read("views/no_digest")
+  end
+
+  private
+
+    def template_digest(name)
+      ActionView::Digestor.digest(name: name, finder: @mailer.lookup_context)
+    end
+end
+
+class CacheHelperOutputBufferTest < BaseCachingTest
+
+  class MockController
+    def read_fragment(name, options)
+      return false
+    end
+
+    def write_fragment(name, fragment, options)
+      fragment
+    end
+  end
+
+  def setup
+    super
+  end
+
+  def test_output_buffer
+    output_buffer = ActionView::OutputBuffer.new
+    controller = MockController.new
+    cache_helper = Class.new do
+      def self.controller; end;
+      def self.output_buffer; end;
+      def self.output_buffer=; end;
+    end
+    cache_helper.extend(ActionView::Helpers::CacheHelper)
+
+    cache_helper.stub :controller, controller do
+      cache_helper.stub :output_buffer, output_buffer do
+        assert_called_with cache_helper, :output_buffer=, [output_buffer.class.new(output_buffer)] do
+          assert_nothing_raised do
+            cache_helper.send :fragment_for, 'Test fragment name', 'Test fragment', &Proc.new{ nil }
+          end
+        end
+      end
+    end
+  end
+
+  def test_safe_buffer
+    output_buffer = ActiveSupport::SafeBuffer.new
+    controller = MockController.new
+    cache_helper = Class.new do
+      def self.controller; end;
+      def self.output_buffer; end;
+      def self.output_buffer=; end;
+    end
+    cache_helper.extend(ActionView::Helpers::CacheHelper)
+
+    cache_helper.stub :controller, controller do
+      cache_helper.stub :output_buffer, output_buffer do
+        assert_called_with cache_helper, :output_buffer=, [output_buffer.class.new(output_buffer)] do
+          assert_nothing_raised do
+            cache_helper.send :fragment_for, 'Test fragment name', 'Test fragment', &Proc.new{ nil }
+          end
+        end
+      end
+    end
+  end
+end
+
+class ViewCacheDependencyTest < BaseCachingTest
+  class NoDependenciesMailer < ActionMailer::Base
+  end
+  class HasDependenciesMailer < ActionMailer::Base
+    view_cache_dependency { "trombone" }
+    view_cache_dependency { "flute" }
+  end
+
+  def test_view_cache_dependencies_are_empty_by_default
+    assert NoDependenciesMailer.new.view_cache_dependencies.empty?
+  end
+
+  def test_view_cache_dependencies_are_listed_in_declaration_order
+    assert_equal %w(trombone flute), HasDependenciesMailer.new.view_cache_dependencies
+  end
+end
diff --git a/actionmailer/test/fixtures/caching_mailer/_partial.html.erb b/actionmailer/test/fixtures/caching_mailer/_partial.html.erb
new file mode 100644
index 0000000000..8e965f52b4
--- /dev/null
+++ b/actionmailer/test/fixtures/caching_mailer/_partial.html.erb
@@ -0,0 +1,3 @@
+<% cache :caching do %>
+  Old fragment caching in a partial
+<% end %>
diff --git a/actionmailer/test/fixtures/caching_mailer/fragment_cache.html.erb b/actionmailer/test/fixtures/caching_mailer/fragment_cache.html.erb
new file mode 100644
index 0000000000..90189627da
--- /dev/null
+++ b/actionmailer/test/fixtures/caching_mailer/fragment_cache.html.erb
@@ -0,0 +1,3 @@
+<% cache :caching do %>
+"Welcome"
+<% end %>
diff --git a/actionmailer/test/fixtures/caching_mailer/fragment_cache_in_partials.html.erb b/actionmailer/test/fixtures/caching_mailer/fragment_cache_in_partials.html.erb
new file mode 100644
index 0000000000..2957d083e8
--- /dev/null
+++ b/actionmailer/test/fixtures/caching_mailer/fragment_cache_in_partials.html.erb
@@ -0,0 +1 @@
+<%= render "partial" %>
diff --git a/actionmailer/test/fixtures/caching_mailer/skip_fragment_cache_digesting.html.erb b/actionmailer/test/fixtures/caching_mailer/skip_fragment_cache_digesting.html.erb
new file mode 100644
index 0000000000..0d52429a81
--- /dev/null
+++ b/actionmailer/test/fixtures/caching_mailer/skip_fragment_cache_digesting.html.erb
@@ -0,0 +1,3 @@
+<%= cache :no_digest, skip_digest: true do %>
+  No Digest
+<% end %>
diff --git a/actionmailer/test/mailers/caching_mailer.rb b/actionmailer/test/mailers/caching_mailer.rb
new file mode 100644
index 0000000000..345d267a36
--- /dev/null
+++ b/actionmailer/test/mailers/caching_mailer.rb
@@ -0,0 +1,15 @@
+class CachingMailer < ActionMailer::Base
+  self.mailer_name = "caching_mailer"
+
+  def fragment_cache
+    mail(subject: "welcome", template_name: "fragment_cache")
+  end
+
+  def fragment_cache_in_partials
+    mail(subject: "welcome", template_name: "fragment_cache_in_partials")
+  end
+
+  def skip_fragment_cache_digesting
+    mail(subject: "welcome", template_name: "skip_fragment_cache_digesting")
+  end
+end
-- 
cgit v1.2.3