diff --git a/Gemfile b/Gemfile index 7d8b316..903b0a4 100644 --- a/Gemfile +++ b/Gemfile @@ -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" diff --git a/Rakefile b/Rakefile index 4980f6d..c681d69 100644 --- a/Rakefile +++ b/Rakefile @@ -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 diff --git a/jbuilder.gemspec b/jbuilder.gemspec index 012d9f9..cac38d3 100644 --- a/jbuilder.gemspec +++ b/jbuilder.gemspec @@ -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' diff --git a/lib/jbuilder/jbuilder_template.rb b/lib/jbuilder/jbuilder_template.rb index 882c030..13233a3 100644 --- a/lib/jbuilder/jbuilder_template.rb +++ b/lib/jbuilder/jbuilder_template.rb @@ -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 @@ -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 @@ -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) @@ -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) diff --git a/test/jbuilder_template_test.rb b/test/jbuilder_template_test.rb index e302881..d3643e6 100644 --- a/test/jbuilder_template_test.rb +++ b/test/jbuilder_template_test.rb @@ -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 @@ -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 @@ -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 @@ -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