Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Caching improvements #298

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ source "https://rubygems.org"
gemspec

gem "rake"
gem "rails", require: false # Version is used by the Rakefile to decide which tests to run
gem "mocha", require: false
gem "appraisal"
gem "pry"
24 changes: 12 additions & 12 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@ require "bundler/setup"
require "bundler/gem_tasks"
require "rake/testtask"

Rake::TestTask.new do |test|
require "rails/version"

test.libs << "test"

if Rails::VERSION::MAJOR == 3
test.test_files = %w[test/jbuilder_template_test.rb test/jbuilder_test.rb]
else
test.test_files = FileList["test/*_test.rb"]
end
end

if !ENV["APPRAISAL_INITIALIZED"] && !ENV["TRAVIS"]
require "appraisal/task"
Appraisal::Task.new
task default: :appraisal
else
Rake::TestTask.new do |test|
require "rails/version"

test.libs << "test"

if Rails::VERSION::MAJOR == 3
test.test_files = %w[test/jbuilder_template_test.rb test/jbuilder_test.rb]
else
test.test_files = FileList["test/*_test.rb"]
end
end

task default: :test
end
1 change: 1 addition & 0 deletions jbuilder.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Gem::Specification.new do |s|

s.required_ruby_version = '>= 1.9.3'

s.add_dependency 'actionpack', '>= 3.0.0', '< 5.1'
s.add_dependency 'activesupport', '>= 3.0.0', '< 5.1'
s.add_dependency 'multi_json', '~> 1.2'

Expand Down
64 changes: 59 additions & 5 deletions lib/jbuilder/jbuilder_template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@ class << self
attr_accessor :template_lookup_options
end

CACHE_REVISION = 3

# Passed to `ActiveSupport::Cache.expand_cache_key` so that we can expire
# old entries if our on-disk format changes
CACHE_TAG = "jbuilder-revision-#{CACHE_REVISION}".to_sym

self.template_lookup_options = { handlers: [:jbuilder] }

def initialize(context, *args)
@context = context
@deferred_caches = {}

super(*args)
end

Expand All @@ -30,13 +38,21 @@ def partial!(*args)
# json.cache! ['v1', @person], expires_in: 10.minutes do
# json.extract! @person, :name, :age
# end
def cache!(key=nil, options={})
def cache!(key=nil, options={}, &block)
if @context.controller.perform_caching
value = _cache_fragment_for(key, options) do
_scope { yield self }
token = "jbuilder-#{::SecureRandom.hex(8)}"

fetcher = -> do
_cache_fragment_for(key, options) do
value = _scope { block.call self }

::MultiJson.dump value
end
end

merge! value
@deferred_caches[token] = fetcher

merge! token => nil
else
yield
end
Expand Down Expand Up @@ -75,6 +91,44 @@ def set!(name, object = BLANK, *args)
end
end

def target!
# Call the superclass implementation to get the output JSON as a string.
output = super

@deferred_caches.each do |token, fetch_block|
value = fetch_block.call
search = "\"#{token}\":null"

if value == "{}".freeze
# Special case to handle empty objects: we remove the search key
# entirely from the output.
search = ::Regexp.new ',?' + ::Regexp.escape(search)
value = "".freeze
end

if value.start_with? "[".freeze
# Special case to handle arrays: we actually have to replace the
# object with the array.
search = "{#{search}}"
end

if value.start_with? "{".freeze
# Remove leading and trailing braces so it'll merge seamlessly into
# the surrounding object.
value = value.slice 1...-1
end

# NOTE: Doing `String#sub` or similar with a `Regexp` will make it try
# to do backreferencing with any backslashes found in the replacement
# string, this will interfere with any potential backslashes in the
# JSON replacement. Therefore we're using `String#[]=` which doesn't
# have that behavior.
output[search] = value
end

output
end

private

def _render_partial_with_options(options)
Expand Down Expand Up @@ -131,7 +185,7 @@ def _cache_key(key, options)
key = url_for(key).split('://', 2).last if ::Hash === key
end

::ActiveSupport::Cache.expand_cache_key(key, :jbuilder)
::ActiveSupport::Cache.expand_cache_key(key, CACHE_TAG)
end

def _fragment_name_with_digest(key, options)
Expand Down
50 changes: 43 additions & 7 deletions test/jbuilder_template_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
require "action_view"
require "action_view/testing/resolvers"
require "active_support/cache"
require "jbuilder/jbuilder_template"
require "jbuilder"

BLOG_POST_PARTIAL = <<-JBUILDER
json.extract! blog_post, :id, :body
Expand Down Expand Up @@ -246,6 +246,42 @@ def assert_collection_rendered(result, context = nil)
assert_equal "bar", result["foo"]
end

test "cache a JSON string" do
undef_context_methods :fragment_name_with_digest, :cache_fragment_name

jbuild <<-JBUILDER
json.cache! "cachekey" do
complex = ::MultiJson.dump 'message' => 'The measurement is 1" too long.'

json.complex complex
end
JBUILDER

result = jbuild(<<-JBUILDER)
json.cache! "cachekey" do
json.complex "Miss"
end
JBUILDER

expected_json = ::MultiJson.dump 'message' => 'The measurement is 1" too long.'

assert_equal expected_json, result["complex"]
end

test "cache a string containing UTF-8" do
undef_context_methods :fragment_name_with_digest, :cache_fragment_name

# The "-" in this template and the literal in the comparison below are
# both in UTF-8
result = jbuild <<-JBUILDER
json.cache! "cachekey" do
json.encoded "a – b"
end
JBUILDER

assert_equal "a – b", result["encoded"]
end

test "fragment caching a JSON object" do
undef_context_methods :fragment_name_with_digest, :cache_fragment_name

Expand Down Expand Up @@ -322,8 +358,7 @@ def assert_collection_rendered(result, context = nil)
test "fragment caching works with current cache digests" do
undef_context_methods :fragment_name_with_digest

@context.expects :cache_fragment_name
ActiveSupport::Cache.expects :expand_cache_key
@context.expects(:cache_fragment_name).returns("cachekey/digest")

jbuild <<-JBUILDER
json.cache! "cachekey" do
Expand Down Expand Up @@ -357,15 +392,16 @@ def assert_collection_rendered(result, context = nil)
end
JBUILDER

assert_equal "jbuilder/cachekey", payloads[:read_fragment][:key]
assert_equal "jbuilder/cachekey", payloads[:write_fragment][:key]
tag = JbuilderTemplate::CACHE_TAG

assert_equal "#{tag}/cachekey", payloads[:read_fragment][:key]
assert_equal "#{tag}/cachekey", payloads[:write_fragment][:key]
end

test "current cache digest option accepts options" do
undef_context_methods :fragment_name_with_digest

@context.expects(:cache_fragment_name).with("cachekey", skip_digest: true)
ActiveSupport::Cache.expects :expand_cache_key
@context.expects(:cache_fragment_name).with("cachekey", skip_digest: true).returns("cachekey")

jbuild <<-JBUILDER
json.cache! "cachekey", skip_digest: true do
Expand Down