decko-commons/decko

View on GitHub
card/lib/card/view/cache.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
90%
class Card
  class View
    # View::Cache supports smart card view caching.
    #
    # The basic idea is that when view caching is turned on (via `config.view_cache`),
    # we try to cache a view whenever it's "safe" to do so. We will include everything
    # inside that view (including other views) until we find something that isn't safe.
    # When something isn't safe, we render a {Stub stub}: a placeholder
    # with all the info we need to come back and replace it with the correct content
    # later. In this way it is possible to have many levels of cached views within
    # cached views.
    #
    # Here are some things that we never consider safe to cache:
    #
    # 1. a view explicitly configured _never_ to be cached
    # 2. a view of a card with view-relevant permission restrictions
    # 3. a view other than the requested view (eg a denial view)
    # 4. a card with unsaved content changes
    #
    # We also consider it unsafe to cache a view of one card within a view of a different
    # card, so nests are always handled with a stub.
    #
    # ## Cache configuration
    #
    # Cache settings (#5) can be configured in the
    # {Set::Format::AbstractFormat#view view definition}
    # and (less commonly) as a {Card::View::Options view option}.
    #
    # By far, the most common explicit caching configuration is `:never`. This setting
    # is used to prevent over-caching, which becomes problematic when data changes
    # do not clear the cache.
    #
    # Generally speaking, a card is smart about clearing its own view caches when
    # anything about the card itself. So when I update the card "Johnny", all the cached
    # views of "Johnny" are cleared. Similarly, changes to structure rules and other
    # basic patterns are typically well managed by the caching system.
    #
    # However, there are many other potential changes that views cannot detect. Views that
    # are susceptible to these "cache hazards" should be configured with `cache: :never`.
    #
    # ## Cache hazards
    #
    # If a view contains any of the following cache hazards, it would be wise to consider
    # a `cache: :never` configuration:
    #
    # - dynamic searches (eg `Card.search`) whose results may change
    # - live timestamps (eg `Time.now`)
    # - environmental variables (eg `Env.params`)
    # - any variables altered in one view and used in another (eg `@myvar`)
    # - other cards' properties (eg `Card["random"].content`)
    #
    # What all of the above have in common is that they involve changes about which the
    # view caching system is unaware. This means that whether the cache hazard is
    # rendered directly in a view or just used in its logic, it can change in a way
    # that _should_ change the view but _won't_ change the view if it's cached.
    #
    # ## Altering cached views
    #
    # Whereas ignoring cache hazards may cause over-caching, altering cached views
    # may cause outright errors. If a view directly alters a rendered view,
    # it may be dangerous to cache.
    #
    #        # obviously safe to cache
    #        view(:x) { "ABC" }
    #
    #        # also safe, because x is NOT altered
    #        view(:y) { render_x + "DEF" }
    #
    #        # unsafe and thus never cached, because x is altered
    #        view(:z, cache: :never) { render_x.reverse }
    #
    # Specifically, the danger is that the inner view will be rendered as a stub,
    # and the out view will end up altering the stub and not the view.
    #
    # Although alterations should be considered dangerous, they are actually only
    # problematic in situations where the inner view might sometimes render a stub.
    # If the outer view is rendering a view of the _same card_ with all the _same view
    # settings_ (perms, unknown, etc), there will be no stub and thus no error.
    # Remember, however, that a view on a narrow set may inherit view settings
    # from a general set. To be confident that a view alteration is safe, all inherited
    # settings must be taken into account.
    #
    # ## Caching Best Practices
    #
    # Here are some good rules of thumb to make good use of view caching:
    #
    # 1. *Use nests.* If you can show the content of a different card with a nest rather
    #    than by showing the content directly, the caching system will be much
    #    happier with you.
    #
    #        view :bad_idea, cache: :never do
    #          Card["random"].content
    #        end
    #
    #        view :good_idea do
    #          nest :random, view: :core
    #        end
    #
    # 2. *Isolate the cache hazards.*  Consider the following variants:
    #
    #        view :bad_idea, cache: :never do
    #          if morning_for_user?
    #            expensive_good_morning
    #          else
    #            expensive_good_afternoon
    #          end
    #        end
    #
    #        view :good_idea, cache: :never do
    #          morning_for_user? ? render_good_morning : render_good_afternoon
    #        end
    #
    #     In the first example, we have to generate expensive greetings every time we
    #     render the view.  In the second, only the test is not cached.
    #
    # 3. If you must alter view results, consider *generating the view content
    #    in a separate method.*
    #
    #        # First Attempt
    #
    #        view :hash_it_in do
    #          { cool: false }
    #        end
    #
    #        view :bad_idea, cache: :never do
    #          render_badhash.merge sucks: true
    #        end
    #
    #
    #        #Second Attempt
    #
    #        view :hash_it_out do
    #          hash_it_out
    #        end
    #
    #        def hash_it_out
    #          { cool: true }
    #        end
    #
    #        view :good_idea do
    #          hash_it_out.merge rocks: true
    #        end
    #
    #     The first attempt will work fine with caching off but is risky with caching on.
    #     The second is safe with caching on.
    #
    # ## Optimizing with `:always`
    #
    # It is never strictly necessary to use `cache: :always`, but this setting can help
    # optimize your use of the caching system in some cases.
    #
    # Consider the following views:
    #
    #        view(:hat) { "hat" } # ...but imagine this is computationally expensive
    #
    #        view(:old_hat)  { "old #{render_hat}"  }
    #        view(:new_hat)  { "new #{render_hat}"  }
    #        view(:red_hat)  { "red #{render_hat}"  }
    #        view(:blue_hat) { "blue #{render_hat}" }
    #
    # Whether "hat" uses `:standard` or `:always`, the hat varieties (old, new, etc...)
    # will fully contain the rendered hat view in their cache. However, with `:standard`,
    # the other views will each re-render hat without attempting to cache it separately
    # or to find it in the cache.  This could lead to man expensive renderings of the
    # "hat" view.  By contrast, if the cache setting is `:always`, then hat will be
    # cached and retrieved even when it's rendered inside another cached view.
    #
    module Cache
      require "card/view/cache/cache_action"
      require "card/view/cache/stub"

      include CacheAction
      include Stub

      private

      # render or retrieve view (or stub) with current options
      # @param block [Block] code block to render
      # @return [rendered view or stub]
      def fetch &block
        case cache_action
        when :yield       then yield                # simple render
        when :cache_yield then cache_render(&block) # render to/from cache
        when :stub        then stub                 # render stub
        end
      end

      # Fetch view via cache and, when appropriate, render its stubs
      #
      # If this is a free cache action (see CacheAction), we go through the stubs and
      # render them now.
      # If the cache is active (ie, we are inside another view), we do not worry about
      # stubs but keep going, because the free cache we're inside will take care of
      # those stubs.
      #
      # @return [String (usually)] rendered view
      def cache_render &block
        cached_view = cache_fetch(&block)
        cache_active? ? cached_view : format.stub_render(cached_view)
      end

      # Is there already a view cache in progress on which this one depends?
      #
      # Note that if you create a brand new independent format object
      # (ie, not a subformat)
      # its activity will be treated as unrelated to this caching/rendering.
      #
      # @return [true/false]
      def cache_active?
        deep_root? ? false : self.class.caching?
      end

      # If view is cached, retrieve it.  Otherwise render and store it.
      # Uses the primary cache API.
      def cache_fetch &block
        caching do
          ensure_cache_key
          self.class.cache.fetch cache_key, &block
        end
      end

      # keep track of nested cache fetching
      def caching &block
        self.class.caching(self, &block)
      end

      # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
      # VIEW CACHE KEY

      def cache_key
        @cache_key ||= [
          card_cache_key, format.class, format.nest_mode, options_for_cache_key
        ].map(&:to_s).join "-"
      end

      def card_cache_key
        card.real? ? card.id : "#{card.key}-#{card.type_id}"
      end

      # Registers the cached view for later clearing in the event of related card changes
      def ensure_cache_key
        card.ensure_view_cache_key cache_key
      end

      def options_for_cache_key
        hash_for_cache_key(live_options) + hash_for_cache_key(viz_hash)
      end

      def hash_for_cache_key hash
        hash.keys.sort.map do |key|
          option_for_cache_key key, hash[key]
        end.join ";"
      end

      def array_for_cache_key array
        # TODO: needs better handling of edit_structure
        #  currently we pass complete structure as nested array
        array.map do |item|
          item.is_a?(Array) ? item.join(":") : item.to_s
        end.sort.join ","
      end

      def option_for_cache_key key, value
        "#{key}:#{option_value_to_string value}"
      end

      def option_value_to_string value
        case value
        when Hash then "{#{hash_for_cache_key value}}"
        when Array then array_for_cache_key(value)
        else value.to_s
        end
      end

      # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
      # cache-related Card::View class methods
      module ClassMethods
        def cache
          Card::Cache[Card::View]
        end

        def caching?
          !@caching.nil?
        end

        def caching voo
          old_caching = @caching
          @caching = voo
          yield
        ensure
          @caching = old_caching
        end
      end
    end
  end
end