Vendor lrbalt's fork of cache_digests

This commit is contained in:
Dan Rice 2013-01-25 21:04:59 -05:00
parent d350685a7a
commit e499441997
25 changed files with 506 additions and 4 deletions

View file

@ -17,7 +17,7 @@ gem "aasm"
gem "htmlentities"
gem "swf_fu"
gem "rails_autolink"
gem "cache_digests", :git => 'git://github.com/lrbalt/cache_digests.git'
gem "cache_digests", :path => 'vendor/gems/cache_digests-0.1.0' # vendored for Ruby 1.8.7 compatibility
gem "rack-mini-profiler"
# Gems used only for assets and not required

View file

@ -1,6 +1,5 @@
GIT
remote: git://github.com/lrbalt/cache_digests.git
revision: 8469c4153c84c0d918b01daccaa1e69747e93e89
PATH
remote: vendor/gems/cache_digests-0.1.0
specs:
cache_digests (0.1.0)
actionpack (>= 3.2)

View file

@ -0,0 +1,2 @@
bin
test/tmp

View file

@ -0,0 +1 @@
1.9.3-p194

View file

@ -0,0 +1,2 @@
source 'https://rubygems.org'
gemspec

View file

@ -0,0 +1,20 @@
Copyright (c) 2012 David Heinemeier Hansson
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,139 @@
Cache Digests
=============
Russian-doll caching schemes are hard to maintain when nested templates are updated. The manual approach works something like this:
```HTML+ERB
# app/views/projects/show.html.erb
<% cache [ "v1", project ] do %>
<h1>All documents</h1>
<%= render project.documents %>
<h1>All todolists</h1>
<%= render project.todolists %>
<% end %>
# app/views/documents/_document.html.erb
<% cache [ "v1", document ] do %>
My document: <%= document.name %>
<%= render document.comments %>
<% end %>
# app/views/todolists/_todolist.html.erb
<% cache [ "v1", todolist ] do %>
My todolist: <%= todolist.name %>
<%= render document.comments %>
<% end %>
# app/views/comments/_comment.html.erb
<% cache [ "v1", comment ] do %>
My comment: <%= comment.body %>
<% end %>
```
Now if I change app/views/comments/_comment.html.erb, I'll be forced to manually track down and bump the other three templates. And there's no visual reference in app/views/projects/show.html.erb that this template even depends on the comment template.
That puts a serious cramp in our rocking caching style.
Enter Cache Digests: With this plugin, all calls to #cache in the view will automatically append a digest of that template _and_ all of it's dependencies! So you no longer need to manually increment versions in the specific templates you're working on or care about what other templates are depending on the change you make.
Our code from above can just look like:
```HTML+ERB
# app/views/projects/show.html.erb
<% cache project do %>
...
# app/views/documents/_document.html.erb
<% cache document do %>
...
# app/views/todolists/_todolist.html.erb
<% cache todolist do %>
...
# app/views/comments/_comment.html.erb
<% cache comment do %>
...
```
The caching key for app/views/projects/show.html.erb will be something like `views/projects/605816632-20120810191209/d9fb66b120b61f46707c67ab41d93cb2`. That last bit is a MD5 of the template file itself and all of its dependencies. It'll change if you change either the template or any of the dependencies, and thus allow the cache to expire automatically.
You can use these handy rake tasks to see how deep the rabbit hole goes:
```
$ rake cache_digests:dependencies TEMPLATE=projects/show
[
"documents/document",
"todolists/todolist"
]
$ rake cache_digests:nested_dependencies TEMPLATE=projects/show
[
{
"documents/document": [
"comments/comment"
]
},
{
"todolists/todolist": [
"comments/comment"
]
}
]
```
Implicit dependencies
---------------------
Most template dependencies can be derived from calls to render in the template itself. Here are some examples of render calls that Cache Digests knows how to decode:
```ruby
render partial: "comments/comment", collection: commentable.comments
render "comments/comments"
render 'comments/comments'
render('comments/comments')
render "header" => render("comments/header")
render(@topic) => render("topics/topic")
render(topics) => render("topics/topic")
render(message.topics) => render("topics/topic")
```
It's not possible to derive all render calls like that, though. Here are a few examples of things that can't be derived:
```ruby
render group_of_attachments
render @project.documents.where(published: true).order('created_at')
```
You will have to rewrite those to the explicit form:
```ruby
render partial: 'attachments/attachment', collection: group_of_attachments
render partial: 'documents/document', collection: @project.documents.where(published: true).order('created_at')
```
Explicit dependencies
---------------------
Some times you'll have template dependencies that can't be derived at all. This is typically the case when you have template rendering that happens in helpers. Here's an example:
```HTML+ERB
<%= render_sortable_todolists @project.todolists %>
```
You'll need to use a special comment format to call those out:
```HTML+ERB
<%# Template Dependency: todolists/todolist %>
<%= render_sortable_todolists @project.todolists %>
```
The pattern used to match these is /# Template Dependency: ([^ ]+)/, so it's important that you type it out just so. You can only declare one template dependency per line.

View file

@ -0,0 +1,11 @@
require 'rubygems'
require 'bundler/setup'
require 'rake'
require 'rake/testtask'
task :default => :test
Rake::TestTask.new do |t|
t.libs << 'test/lib'
t.pattern = 'test/*_test.rb'
end

View file

@ -0,0 +1,16 @@
Gem::Specification.new do |s|
s.name = 'cache_digests'
s.version = '0.1.0'
s.author = 'David Heinemeier Hansson'
s.email = 'david@37signals.com'
s.summary = 'Nested fragment caches with (even) less situps'
s.required_ruby_version = '>= 1.8.7'
s.add_dependency 'actionpack', '>= 3.2'
s.add_development_dependency 'rake'
s.add_development_dependency 'minitest'
s.files = Dir["#{File.dirname(__FILE__)}/**/*"]
end

View file

@ -0,0 +1,4 @@
require 'digest/md5'
require 'cache_digests/template_digestor'
require 'cache_digests/fragment_helper'
require 'cache_digests/engine' if defined?(Rails)

View file

@ -0,0 +1,15 @@
module CacheDigests
class Engine < ::Rails::Engine
initializer 'cache_digests' do |app|
require 'cache_digests'
ActiveSupport.on_load :action_view do
ActionView::Base.send :include, CacheDigests::FragmentHelper
end
config.to_prepare do
CacheDigests::TemplateDigestor.logger = Rails.logger
end
end
end
end

View file

@ -0,0 +1,17 @@
module CacheDigests
module FragmentHelper
private
# Automatically include this template's digest -- and its childrens' -- in the cache key.
def fragment_for(key, options = nil, &block)
if !explicitly_versioned_cache_key?(key)
super [key, TemplateDigestor.digest(@virtual_path, formats.last.to_sym, lookup_context)], options, &block
else
super
end
end
def explicitly_versioned_cache_key?(key)
key.is_a?(Array) && key.first =~ /\Av\d+\Z/
end
end
end

View file

@ -0,0 +1,104 @@
require 'active_support/core_ext'
require 'logger'
module CacheDigests
class TemplateDigestor
EXPLICIT_DEPENDENCY = /# Template Dependency: ([^ ]+)/
# Matches:
# render partial: "comments/comment", collection: commentable.comments
# render "comments/comments"
# render 'comments/comments'
# render('comments/comments')
#
# render(@topic) => render("topics/topic")
# render(topics) => render("topics/topic")
# render(message.topics) => render("topics/topic")
RENDER_DEPENDENCY = /
render\s? # render, followed by an optional space
\(? # start a optional parenthesis for the render call
(partial:)?\s? # naming the partial, used with collection -- 1st capture
([@a-z"'][@a-z_\/\."']+) # the template name itself -- 2nd capture
/x
cattr_accessor(:cache) { Hash.new }
cattr_accessor(:logger, :instance_reader => true)
def self.digest(name, format, finder, options = {})
cache["#{name}.#{format}"] ||= new(name, format, finder, options).digest
end
attr_reader :name, :format, :finder, :options
def initialize(name, format, finder, options = {})
@name, @format, @finder, @options = name, format, finder, options
end
def digest
Digest::MD5.hexdigest("#{name}.#{format}-#{source}-#{dependency_digest}").tap do |digest|
logger.try :info, "Cache digest for #{name}.#{format}: #{digest}"
end
rescue ActionView::MissingTemplate
logger.try :error, "Couldn't find template for digesting: #{name}.#{format}"
''
end
def dependencies
render_dependencies + explicit_dependencies
rescue ActionView::MissingTemplate
[] # File doesn't exist, so no dependencies
end
def nested_dependencies
dependencies.collect do |dependency|
dependencies = TemplateDigestor.new(dependency, format, finder, :partial => true).nested_dependencies
dependencies.any? ? { dependency => dependencies } : dependency
end
end
private
def logical_name
name.gsub(%r|/_|, "/")
end
def directory
name.split("/").first
end
def partial?
options[:partial] || name.include?("/_")
end
def source
@source ||= finder.find(logical_name, [], partial?, :formats => [ format ]).source
end
def dependency_digest
dependencies.collect do |template_name|
TemplateDigestor.digest(template_name, format, finder, :partial => true)
end.join("-")
end
def render_dependencies
source.scan(RENDER_DEPENDENCY).
collect(&:second).uniq.
# render(@topic) => render("topics/topic")
# render(topics) => render("topics/topic")
# render(message.topics) => render("topics/topic")
collect { |name| name.sub(/\A@?([a-z]+\.)*([a-z_]+)\z/) { "#{$2.pluralize}/#{$2.singularize}" } }.
# render("headline") => render("message/headline")
collect { |name| name.include?("/") ? name : "#{directory}/#{name}" }.
# replace quotes from string renders
collect { |name| name.gsub(/["']/, "") }
end
def explicit_dependencies
source.scan(EXPLICIT_DEPENDENCY).flatten.uniq
end
end
end

View file

@ -0,0 +1,15 @@
namespace :cache_digests do
desc 'Lookup nested dependencies for TEMPLATE (like messages/show or comments/_comment.html)'
task :nested_dependencies => :environment do
template, format = ENV['TEMPLATE'].split(".")
format ||= :html
puts JSON.pretty_generate CacheDigests::TemplateDigestor.new(template, format, ApplicationController.new.lookup_context).nested_dependencies
end
desc 'Lookup first-level dependencies for TEMPLATE (like messages/show or comments/_comment.html)'
task :dependencies => :environment do
template, format = ENV['TEMPLATE'].split(".")
format ||= :html
puts JSON.pretty_generate CacheDigests::TemplateDigestor.new(template, format, ApplicationController.new.lookup_context).dependencies
end
end

View file

@ -0,0 +1 @@
Great story, bro!

View file

@ -0,0 +1 @@
<%= render partial: "comments/comment", collection: commentable.comments %>

View file

@ -0,0 +1 @@
THIS BE WHERE THEM MESSAGE GO, YO!

View file

@ -0,0 +1,2 @@
<%= render @messages %>
<%= render @events %>

View file

@ -0,0 +1,9 @@
<%# Template Dependency: messages/message %>
<%= render "header" %>
<%= render "comments/comments" %>
<%= render "messages/actions/move" %>
<%= render @message.history.events %>
<%# render "something_missing" %>

View file

@ -0,0 +1,4 @@
require 'cache_digests/test_helper'
class FragmentHelperTest < MiniTest::Unit::TestCase
end

View file

@ -0,0 +1,6 @@
require 'rubygems'
require 'bundler/setup'
require 'cache_digests'
require 'minitest/unit'
MiniTest::Unit.autorun

View file

@ -0,0 +1,133 @@
require 'cache_digests/test_helper'
require 'fileutils'
module ActionView
class MissingTemplate < StandardError
end
end
class FixtureTemplate
attr_reader :source
def initialize(template_path)
@source = File.read(template_path)
rescue Errno::ENOENT
raise ActionView::MissingTemplate
end
end
class FixtureFinder
FIXTURES_DIR = "#{File.dirname(__FILE__)}/fixtures"
TMP_DIR = "#{File.dirname(__FILE__)}/tmp"
def find(logical_name, keys, partial, options)
FixtureTemplate.new("#{TMP_DIR}/#{partial ? logical_name.gsub(%r|/([^/]+)$|, '/_\1') : logical_name}.#{options[:formats].first}.erb")
end
end
class TemplateDigestorTest < MiniTest::Unit::TestCase
def setup
FileUtils.cp_r FixtureFinder::FIXTURES_DIR, FixtureFinder::TMP_DIR
end
def teardown
FileUtils.rm_r FixtureFinder::TMP_DIR
CacheDigests::TemplateDigestor.cache.clear
end
def test_top_level_change_reflected
assert_digest_difference("messages/show") do
change_template("messages/show")
end
end
def test_explicit_dependency
assert_digest_difference("messages/show") do
change_template("messages/_message")
end
end
def test_second_level_dependency
assert_digest_difference("messages/show") do
change_template("comments/_comments")
end
end
def test_second_level_dependency_within_same_directory
assert_digest_difference("messages/show") do
change_template("messages/_header")
end
end
def test_third_level_dependency
assert_digest_difference("messages/show") do
change_template("comments/_comment")
end
end
def test_logging_of_missing_template
assert_logged "Couldn't find template for digesting: messages/something_missing.html" do
digest("messages/show")
end
end
def test_nested_template_directory
assert_digest_difference("messages/show") do
change_template("messages/actions/_move")
end
end
def test_dont_generate_a_digest_for_missing_templates
assert_equal '', digest("nothing/there")
end
def test_collection_dependency
assert_digest_difference("messages/index") do
change_template("messages/_message")
end
assert_digest_difference("messages/index") do
change_template("events/_event")
end
end
def test_collection_derived_from_record_dependency
assert_digest_difference("messages/show") do
change_template("events/_event")
end
end
private
def assert_logged(message)
log = StringIO.new
CacheDigests::TemplateDigestor.logger = Logger.new(log)
yield
log.rewind
assert_match message, log.read
CacheDigests::TemplateDigestor.logger = nil
end
def assert_digest_difference(template_name)
previous_digest = digest(template_name)
CacheDigests::TemplateDigestor.cache.clear
yield
assert previous_digest != digest(template_name), "digest didn't change"
CacheDigests::TemplateDigestor.cache.clear
end
def digest(template_name)
CacheDigests::TemplateDigestor.digest(template_name, :html, FixtureFinder.new)
end
def change_template(template_name)
File.open("#{FixtureFinder::TMP_DIR}/#{template_name}.html.erb", "w") do |f|
f.write "\nTHIS WAS CHANGED!"
end
end
end