├── .gitignore ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── benchmark └── base_classes.rb ├── lib └── rails │ └── patch │ └── json │ ├── encode.rb │ └── encode │ └── version.rb ├── rails-patch-json-encode.gemspec └── test ├── abstract_unit.rb ├── encoding_test.rb ├── encoding_test_cases.rb └── time_zone_test_helpers.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in rails-patch-json-encode.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 lulalala 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rails::Patch::Json::Encode 2 | 3 | This is a monkey patch for Rails in order to speed up its JSON encoding, and to draw people's attention to this [Rails issue](https://github.com/rails/rails/issues/9212). 4 | 5 | For full details please read Jason Hutchens' [blog post](http://devblog.agworld.com.au/post/42586025923/the-performance-of-to-json-in-rails-sucks-and-theres). 6 | 7 | All credits goes to [Jason Hutchens](https://github.com/jasonhutchens) for discovering the issue and providing the code for this monkey patch. 8 | 9 | ## Installation 10 | 11 | First, let's measure the time before the patch. 12 | Go to your Rails console and type: 13 | 14 | require 'benchmark' 15 | DATA = Hash.new 16 | key = 'aaa' 17 | 1000.times { DATA[key.succ!] = DATA.keys } 18 | Benchmark.realtime { 5.times { DATA.to_json } } 19 | 20 | Then bundle install this gem with a fast JSON encoding gem in your Rails' Gemfile. 21 | 22 | gem 'rails-patch-json-encode' 23 | gem 'yajl-ruby', require: 'yajl' 24 | 25 | In this case I choose the yajl-ruby gem, but you can [choose a json-encoder gem that multi_json supports](https://github.com/intridea/multi_json#supported-json-engines). 26 | 27 | The final step is to choose a patch. Two types of patches are available, and you have to choose one and invoke it explictly: 28 | 29 | * `Rails::Patch::Json::Encode.patch_base_classes` patches all Ruby base classes. 30 | * `Rails::Patch::Json::Encode.patch_renderers` patches Rails' ActionController::Renderers only. This is for those who had issue with the JSON gem, as patching base classes cause infinite recursive loop. 31 | 32 | Place one of them in a Rails initializer (e.g. `config/initializers/rails_patch_json_encode.rb`), and Rails should now use the faster encoder. 33 | 34 | Now it's done. Reopen your Rails console and rerun the benchmark to see the difference. 35 | 36 | ## Warning 37 | 38 | If you are using Oj gem, there is no need to install this gem. Call `Oj.optimize_rails` instead. 39 | 40 | Rails in recent years added safety nets to handle nested NaN, infinity and IO objects. This gem does not handle these cases. 41 | 42 | This gem may break your app. **Test your app**. 43 | 44 | ## Benchmark 45 | 46 | `rake benchmark` is provided to show the difference before and after the patch. From my machine the time is dropped to 14% when using yajl on Rails 5.2. 47 | 48 | The actual performance boost on real-world applications will probably be less than that. For one of my page I see the rendering time dropped by 25%. 49 | 50 | ## What's with the name 51 | 52 | This is just a temporal monkey patch, and a monkey patch isn't supposed to have a fancy name. 53 | 54 | ## Related reading 55 | 56 | * Jason Hutchen's [blog post](http://devblog.agworld.com.au/post/42586025923/the-performance-of-to-json-in-rails-sucks-and-theres) 57 | * [Rails issue](https://github.com/rails/rails/issues/9212) 58 | * [Current refactoring done by chancancode](https://github.com/rails/rails/pull/12183) trying to address this issue. 59 | * [A pull-request related to this](https://github.com/intridea/multi_json/pull/138) about JSON and Rails trying to patch the same method* 60 | * [Original issue and fix that resulted in this issue](https://rails.lighthouseapp.com/projects/8994/tickets/4890) 61 | 62 | 63 | ## Contributing 64 | 65 | 1. Fork it 66 | 2. Create your feature branch (`git checkout -b my-new-feature`) 67 | 3. Commit your changes (`git commit -am 'Add some feature'`) 68 | 4. Push to the branch (`git push origin my-new-feature`) 69 | 5. Create new Pull Request 70 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | task default: :test 4 | 5 | desc "Benchmark" 6 | task :benchmark do 7 | require_relative 'benchmark/base_classes' 8 | end 9 | 10 | ### Test 11 | require "rake/testtask" 12 | dir = File.dirname(__FILE__) 13 | Rake::TestTask.new do |t| 14 | t.libs << "test" 15 | t.test_files = Dir.glob("#{dir}/test/**/*_test.rb") 16 | t.warning = true 17 | t.verbose = true 18 | t.ruby_opts = ["--dev"] if defined?(JRUBY_VERSION) 19 | end 20 | -------------------------------------------------------------------------------- /benchmark/base_classes.rb: -------------------------------------------------------------------------------- 1 | require 'benchmark' 2 | require 'json' 3 | require 'yajl' 4 | require 'active_support' 5 | require 'active_support/json' 6 | require 'active_support/core_ext/object/json' 7 | require 'rails/patch/json/encode' 8 | 9 | puts < @obj` is called. 7 | def self.patch_renderers 8 | ::ActionController::Renderers.module_eval do 9 | # Override 10 | add :json do |json, options| 11 | json = MultiJson::dump(json.as_json(options), options) unless json.kind_of?(String) 12 | 13 | if options[:callback].present? 14 | self.content_type ||= Mime::JS 15 | "#{options[:callback]}(#{json})" 16 | else 17 | self.content_type ||= Mime::JSON 18 | json 19 | end 20 | end 21 | end 22 | end 23 | 24 | 25 | 26 | # Combine http://devblog.agworld.com.au/post/42586025923/the-performance-of-to-json-in-rails-sucks-and-theres 27 | # and Rails' ToJsonWithActiveSupportEncoder together, 28 | # essentially reversing Rails' hard-coded call to ActiveSupport::JSON.encode 29 | module ToJsonWithMultiJson 30 | def to_json(options = {}) 31 | if options.is_a?(::JSON::State) 32 | super(options) 33 | else 34 | ::MultiJson::dump(self.as_json(options), options) 35 | end 36 | end 37 | end 38 | 39 | def self.patch_base_classes 40 | [Object, Array, FalseClass, Float, Hash, Integer, NilClass, String, TrueClass, Enumerable].reverse_each do |klass| 41 | klass.prepend(ToJsonWithMultiJson) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/rails/patch/json/encode/version.rb: -------------------------------------------------------------------------------- 1 | module Rails 2 | module Patch 3 | module Json 4 | module Encode 5 | VERSION = "0.2.0" 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /rails-patch-json-encode.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'rails/patch/json/encode/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "rails-patch-json-encode" 8 | spec.version = Rails::Patch::Json::Encode::VERSION 9 | spec.authors = ["lulalala", "Jason Hutchens"] 10 | spec.email = ["mark@goodlife.tw"] 11 | spec.description = %q{A monkey patch to speed up Rails' JSON generation time.} 12 | spec.summary = %q{A monkey patch to speed up Rails' JSON generation time.} 13 | spec.homepage = "https://github.com/GoodLife/rails-patch-json-encode" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler", "~> 1.16.0" 22 | spec.add_development_dependency "rake", "~> 12.3.0" 23 | spec.add_development_dependency "activesupport", "~> 5.1.4" 24 | spec.add_development_dependency "yajl-ruby" 25 | spec.add_development_dependency "byebug" 26 | 27 | spec.add_dependency 'multi_json', '>= 1.9.3', '~> 1.0' 28 | end 29 | -------------------------------------------------------------------------------- /test/abstract_unit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ORIG_ARGV = ARGV.dup 4 | 5 | require "active_support/core_ext/kernel/reporting" 6 | 7 | silence_warnings do 8 | Encoding.default_internal = Encoding::UTF_8 9 | Encoding.default_external = Encoding::UTF_8 10 | end 11 | 12 | require "active_support/testing/autorun" 13 | require "active_support/testing/method_call_assertions" 14 | 15 | ENV["NO_RELOAD"] = "1" 16 | require "active_support" 17 | 18 | Thread.abort_on_exception = true 19 | 20 | # Show backtraces for deprecated behavior for quicker cleanup. 21 | ActiveSupport::Deprecation.debug = true 22 | 23 | # Default to old to_time behavior but allow running tests with new behavior 24 | ActiveSupport.to_time_preserves_timezone = ENV["PRESERVE_TIMEZONES"] == "1" 25 | 26 | # Disable available locale checks to avoid warnings running the test suite. 27 | I18n.enforce_available_locales = false 28 | 29 | class ActiveSupport::TestCase 30 | include ActiveSupport::Testing::MethodCallAssertions 31 | 32 | # Skips the current run on Rubinius using Minitest::Assertions#skip 33 | private def rubinius_skip(message = "") 34 | skip message if RUBY_ENGINE == "rbx" 35 | end 36 | 37 | # Skips the current run on JRuby using Minitest::Assertions#skip 38 | private def jruby_skip(message = "") 39 | skip message if defined?(JRUBY_VERSION) 40 | end 41 | 42 | def frozen_error_class 43 | Object.const_defined?(:FrozenError) ? FrozenError : RuntimeError 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/encoding_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'yajl' 4 | require "securerandom" 5 | require "abstract_unit" 6 | require "active_support/core_ext/string/inflections" 7 | require "active_support/core_ext/regexp" 8 | require "active_support/json" 9 | require "active_support/time" 10 | require "time_zone_test_helpers" 11 | require "encoding_test_cases" 12 | require 'rails/patch/json/encode' 13 | 14 | Rails::Patch::Json::Encode.patch_base_classes 15 | 16 | class TestJSONEncoding < ActiveSupport::TestCase 17 | include TimeZoneTestHelpers 18 | 19 | def sorted_json(json) 20 | if json.start_with?("{") && json.end_with?("}") 21 | "{" + json[1..-2].split(",").sort.join(",") + "}" 22 | else 23 | json 24 | end 25 | end 26 | 27 | JSONTest::EncodingTestCases.constants.each do |class_tests| 28 | define_method("test_#{class_tests[0..-6].underscore}") do 29 | begin 30 | prev = ActiveSupport.use_standard_json_time_format 31 | 32 | standard_class_tests = /Standard/.match?(class_tests) 33 | 34 | ActiveSupport.escape_html_entities_in_json = !standard_class_tests 35 | ActiveSupport.use_standard_json_time_format = standard_class_tests 36 | JSONTest::EncodingTestCases.const_get(class_tests).each do |pair| 37 | assert_equal pair.last, sorted_json(ActiveSupport::JSON.encode(pair.first)) 38 | end 39 | ensure 40 | ActiveSupport.escape_html_entities_in_json = false 41 | ActiveSupport.use_standard_json_time_format = prev 42 | end 43 | end 44 | end 45 | 46 | def test_process_status 47 | rubinius_skip "https://github.com/rubinius/rubinius/issues/3334" 48 | 49 | # There doesn't seem to be a good way to get a handle on a Process::Status object without actually 50 | # creating a child process, hence this to populate $? 51 | system("not_a_real_program_#{SecureRandom.hex}") 52 | assert_equal %({"exitstatus":#{$?.exitstatus},"pid":#{$?.pid}}), ActiveSupport::JSON.encode($?) 53 | end 54 | 55 | def test_hash_encoding 56 | assert_equal %({\"a\":\"b\"}), ActiveSupport::JSON.encode(a: :b) 57 | assert_equal %({\"a\":1}), ActiveSupport::JSON.encode("a" => 1) 58 | assert_equal %({\"a\":[1,2]}), ActiveSupport::JSON.encode("a" => [1, 2]) 59 | assert_equal %({"1":2}), ActiveSupport::JSON.encode(1 => 2) 60 | 61 | assert_equal %({\"a\":\"b\",\"c\":\"d\"}), sorted_json(ActiveSupport::JSON.encode(a: :b, c: :d)) 62 | end 63 | 64 | def test_hash_keys_encoding 65 | ActiveSupport.escape_html_entities_in_json = true 66 | assert_equal "{\"\\u003c\\u003e\":\"\\u003c\\u003e\"}", ActiveSupport::JSON.encode("<>" => "<>") 67 | ensure 68 | ActiveSupport.escape_html_entities_in_json = false 69 | end 70 | 71 | def test_utf8_string_encoded_properly 72 | result = ActiveSupport::JSON.encode("€2.99") 73 | assert_equal '"€2.99"', result 74 | assert_equal(Encoding::UTF_8, result.encoding) 75 | 76 | result = ActiveSupport::JSON.encode("✎☺") 77 | assert_equal '"✎☺"', result 78 | assert_equal(Encoding::UTF_8, result.encoding) 79 | end 80 | 81 | def test_non_utf8_string_transcodes 82 | s = "二".encode("Shift_JIS") 83 | result = ActiveSupport::JSON.encode(s) 84 | assert_equal '"二"', result 85 | assert_equal Encoding::UTF_8, result.encoding 86 | end 87 | 88 | def test_wide_utf8_chars 89 | w = "𠜎" 90 | result = ActiveSupport::JSON.encode(w) 91 | assert_equal '"𠜎"', result 92 | end 93 | 94 | def test_wide_utf8_roundtrip 95 | hash = { string: "𐒑" } 96 | json = ActiveSupport::JSON.encode(hash) 97 | decoded_hash = ActiveSupport::JSON.decode(json) 98 | assert_equal "𐒑", decoded_hash["string"] 99 | end 100 | 101 | def test_hash_key_identifiers_are_always_quoted 102 | values = { 0 => 0, 1 => 1, :_ => :_, "$" => "$", "a" => "a", :A => :A, :A0 => :A0, "A0B" => "A0B" } 103 | assert_equal %w( "$" "A" "A0" "A0B" "_" "a" "0" "1" ).sort, object_keys(ActiveSupport::JSON.encode(values)) 104 | end 105 | 106 | def test_hash_should_allow_key_filtering_with_only 107 | assert_equal %({"a":1}), ActiveSupport::JSON.encode({ "a" => 1, :b => 2, :c => 3 }, { only: "a" }) 108 | end 109 | 110 | def test_hash_should_allow_key_filtering_with_except 111 | assert_equal %({"b":2}), ActiveSupport::JSON.encode({ "foo" => "bar", :b => 2, :c => 3 }, { except: ["foo", :c] }) 112 | end 113 | 114 | def test_time_to_json_includes_local_offset 115 | with_standard_json_time_format(true) do 116 | with_env_tz "US/Eastern" do 117 | assert_equal %("2005-02-01T15:15:10.000-05:00"), ActiveSupport::JSON.encode(Time.local(2005, 2, 1, 15, 15, 10)) 118 | end 119 | end 120 | end 121 | 122 | def test_hash_with_time_to_json 123 | with_standard_json_time_format(false) do 124 | assert_equal '{"time":"2009/01/01 00:00:00 +0000"}', { time: Time.utc(2009) }.to_json 125 | end 126 | end 127 | 128 | def test_nested_hash_with_float 129 | assert_nothing_raised do 130 | hash = { 131 | "CHI" => { 132 | display_name: "chicago", 133 | latitude: 123.234 134 | } 135 | } 136 | ActiveSupport::JSON.encode(hash) 137 | end 138 | end 139 | 140 | def test_hash_like_with_options 141 | h = JSONTest::Hashlike.new 142 | json = h.to_json only: [:foo] 143 | 144 | assert_equal({ "foo" => "hello" }, JSON.parse(json)) 145 | end 146 | 147 | def test_object_to_json_with_options 148 | obj = Object.new 149 | obj.instance_variable_set :@foo, "hello" 150 | obj.instance_variable_set :@bar, "world" 151 | json = obj.to_json only: ["foo"] 152 | 153 | assert_equal({ "foo" => "hello" }, JSON.parse(json)) 154 | end 155 | 156 | def test_struct_to_json_with_options 157 | struct = Struct.new(:foo, :bar).new 158 | struct.foo = "hello" 159 | struct.bar = "world" 160 | json = struct.to_json only: [:foo] 161 | 162 | assert_equal({ "foo" => "hello" }, JSON.parse(json)) 163 | end 164 | 165 | def test_hash_should_pass_encoding_options_to_children_in_as_json 166 | person = { 167 | name: "John", 168 | address: { 169 | city: "London", 170 | country: "UK" 171 | } 172 | } 173 | json = person.as_json only: [:address, :city] 174 | 175 | assert_equal({ "address" => { "city" => "London" } }, json) 176 | end 177 | 178 | def test_hash_should_pass_encoding_options_to_children_in_to_json 179 | person = { 180 | name: "John", 181 | address: { 182 | city: "London", 183 | country: "UK" 184 | } 185 | } 186 | json = person.to_json only: [:address, :city] 187 | 188 | assert_equal(%({"address":{"city":"London"}}), json) 189 | end 190 | 191 | def test_array_should_pass_encoding_options_to_children_in_as_json 192 | people = [ 193 | { name: "John", address: { city: "London", country: "UK" } }, 194 | { name: "Jean", address: { city: "Paris", country: "France" } } 195 | ] 196 | json = people.as_json only: [:address, :city] 197 | expected = [ 198 | { "address" => { "city" => "London" } }, 199 | { "address" => { "city" => "Paris" } } 200 | ] 201 | 202 | assert_equal(expected, json) 203 | end 204 | 205 | def test_array_should_pass_encoding_options_to_children_in_to_json 206 | people = [ 207 | { name: "John", address: { city: "London", country: "UK" } }, 208 | { name: "Jean", address: { city: "Paris", country: "France" } } 209 | ] 210 | json = people.to_json only: [:address, :city] 211 | 212 | assert_equal(%([{"address":{"city":"London"}},{"address":{"city":"Paris"}}]), json) 213 | end 214 | 215 | People = Class.new(BasicObject) do 216 | include Enumerable 217 | def initialize 218 | @people = [ 219 | { name: "John", address: { city: "London", country: "UK" } }, 220 | { name: "Jean", address: { city: "Paris", country: "France" } } 221 | ] 222 | end 223 | def each(*, &blk) 224 | @people.each do |p| 225 | yield p if blk 226 | p 227 | end.each 228 | end 229 | end 230 | 231 | def test_enumerable_should_generate_json_with_as_json 232 | json = People.new.as_json only: [:address, :city] 233 | expected = [ 234 | { "address" => { "city" => "London" } }, 235 | { "address" => { "city" => "Paris" } } 236 | ] 237 | 238 | assert_equal(expected, json) 239 | end 240 | 241 | def test_enumerable_should_generate_json_with_to_json 242 | json = People.new.to_json only: [:address, :city] 243 | assert_equal(%([{"address":{"city":"London"}},{"address":{"city":"Paris"}}]), json) 244 | end 245 | 246 | def test_enumerable_should_pass_encoding_options_to_children_in_as_json 247 | json = People.new.each.as_json only: [:address, :city] 248 | expected = [ 249 | { "address" => { "city" => "London" } }, 250 | { "address" => { "city" => "Paris" } } 251 | ] 252 | 253 | assert_equal(expected, json) 254 | end 255 | 256 | def test_enumerable_should_pass_encoding_options_to_children_in_to_json 257 | json = People.new.each.to_json only: [:address, :city] 258 | 259 | assert_equal(%([{"address":{"city":"London"}},{"address":{"city":"Paris"}}]), json) 260 | end 261 | 262 | class CustomWithOptions 263 | attr_accessor :foo, :bar 264 | 265 | def as_json(options = {}) 266 | options[:only] = %w(foo bar) 267 | super(options) 268 | end 269 | end 270 | 271 | def test_hash_to_json_should_not_keep_options_around 272 | f = CustomWithOptions.new 273 | f.foo = "hello" 274 | f.bar = "world" 275 | 276 | hash = { "foo" => f, "other_hash" => { "foo" => "other_foo", "test" => "other_test" } } 277 | assert_equal({ "foo" => { "foo" => "hello", "bar" => "world" }, 278 | "other_hash" => { "foo" => "other_foo", "test" => "other_test" } }, ActiveSupport::JSON.decode(hash.to_json)) 279 | end 280 | 281 | def test_array_to_json_should_not_keep_options_around 282 | f = CustomWithOptions.new 283 | f.foo = "hello" 284 | f.bar = "world" 285 | 286 | array = [f, { "foo" => "other_foo", "test" => "other_test" }] 287 | assert_equal([{ "foo" => "hello", "bar" => "world" }, 288 | { "foo" => "other_foo", "test" => "other_test" }], ActiveSupport::JSON.decode(array.to_json)) 289 | end 290 | 291 | class OptionsTest 292 | def as_json(options = :default) 293 | options 294 | end 295 | end 296 | 297 | def test_hash_as_json_without_options 298 | json = { foo: OptionsTest.new }.as_json 299 | assert_equal({ "foo" => :default }, json) 300 | end 301 | 302 | def test_array_as_json_without_options 303 | json = [ OptionsTest.new ].as_json 304 | assert_equal([:default], json) 305 | end 306 | 307 | def test_struct_encoding 308 | Struct.new("UserNameAndEmail", :name, :email) 309 | Struct.new("UserNameAndDate", :name, :date) 310 | Struct.new("Custom", :name, :sub) 311 | user_email = Struct::UserNameAndEmail.new "David", "sample@example.com" 312 | user_birthday = Struct::UserNameAndDate.new "David", Date.new(2010, 01, 01) 313 | custom = Struct::Custom.new "David", user_birthday 314 | 315 | json_strings = "" 316 | json_string_and_date = "" 317 | json_custom = "" 318 | 319 | assert_nothing_raised do 320 | json_strings = user_email.to_json 321 | json_string_and_date = user_birthday.to_json 322 | json_custom = custom.to_json 323 | end 324 | 325 | assert_equal({ "name" => "David", 326 | "sub" => { 327 | "name" => "David", 328 | "date" => "2010-01-01" } }, ActiveSupport::JSON.decode(json_custom)) 329 | 330 | assert_equal({ "name" => "David", "email" => "sample@example.com" }, 331 | ActiveSupport::JSON.decode(json_strings)) 332 | 333 | assert_equal({ "name" => "David", "date" => "2010-01-01" }, 334 | ActiveSupport::JSON.decode(json_string_and_date)) 335 | end 336 | 337 | def test_nil_true_and_false_represented_as_themselves 338 | assert_nil nil.as_json 339 | assert_equal true, true.as_json 340 | assert_equal false, false.as_json 341 | end 342 | 343 | class HashWithAsJson < Hash 344 | attr_accessor :as_json_called 345 | 346 | def initialize(*) 347 | super 348 | end 349 | 350 | def as_json(options = {}) 351 | @as_json_called = true 352 | super 353 | end 354 | end 355 | 356 | def test_json_gem_dump_by_passing_active_support_encoder 357 | h = HashWithAsJson.new 358 | h[:foo] = "hello" 359 | h[:bar] = "world" 360 | 361 | assert_equal %({"foo":"hello","bar":"world"}), JSON.dump(h) 362 | assert_nil h.as_json_called 363 | end 364 | 365 | def test_json_gem_generate_by_passing_active_support_encoder 366 | h = HashWithAsJson.new 367 | h[:foo] = "hello" 368 | h[:bar] = "world" 369 | 370 | assert_equal %({"foo":"hello","bar":"world"}), JSON.generate(h) 371 | assert_nil h.as_json_called 372 | end 373 | 374 | def test_json_gem_pretty_generate_by_passing_active_support_encoder 375 | h = HashWithAsJson.new 376 | h[:foo] = "hello" 377 | h[:bar] = "world" 378 | 379 | assert_equal < Float::INFINITY } 444 | end 445 | end 446 | 447 | def test_to_json_works_when_as_json_returns_infinite_number 448 | assert_equal '{"number":null}', InfiniteNumber.new.to_json 449 | end 450 | 451 | class NaNNumber 452 | def as_json(options = nil) 453 | { "number" => Float::NAN } 454 | end 455 | end 456 | 457 | def test_to_json_works_when_as_json_returns_NaN_number 458 | assert_equal '{"number":null}', NaNNumber.new.to_json 459 | end 460 | 461 | def test_to_json_works_on_io_objects 462 | assert_equal STDOUT.to_s.to_json, STDOUT.to_json 463 | end 464 | 465 | private 466 | 467 | def object_keys(json_object) 468 | json_object[1..-2].scan(/([^{}:,\s]+):/).flatten.sort 469 | end 470 | 471 | def with_standard_json_time_format(boolean = true) 472 | old, ActiveSupport.use_standard_json_time_format = ActiveSupport.use_standard_json_time_format, boolean 473 | yield 474 | ensure 475 | ActiveSupport.use_standard_json_time_format = old 476 | end 477 | 478 | def with_time_precision(value) 479 | old_value = ActiveSupport::JSON::Encoding.time_precision 480 | ActiveSupport::JSON::Encoding.time_precision = value 481 | yield 482 | ensure 483 | ActiveSupport::JSON::Encoding.time_precision = old_value 484 | end 485 | end 486 | -------------------------------------------------------------------------------- /test/encoding_test_cases.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bigdecimal" 4 | require "date" 5 | require "time" 6 | require "pathname" 7 | require "uri" 8 | 9 | module JSONTest 10 | class Foo 11 | def initialize(a, b) 12 | @a, @b = a, b 13 | end 14 | end 15 | 16 | class Hashlike 17 | def to_hash 18 | { foo: "hello", bar: "world" } 19 | end 20 | end 21 | 22 | class Custom 23 | def initialize(serialized) 24 | @serialized = serialized 25 | end 26 | 27 | def as_json(options = nil) 28 | @serialized 29 | end 30 | end 31 | 32 | MyStruct = Struct.new(:name, :value) do 33 | def initialize(*) 34 | @unused = "unused instance variable" 35 | super 36 | end 37 | end 38 | 39 | module EncodingTestCases 40 | TrueTests = [[ true, %(true) ]] 41 | FalseTests = [[ false, %(false) ]] 42 | NilTests = [[ nil, %(null) ]] 43 | NumericTests = [[ 1, %(1) ], 44 | [ 2.5, %(2.5) ], 45 | [ 0.0 / 0.0, %(null) ], 46 | [ 1.0 / 0.0, %(null) ], 47 | [ -1.0 / 0.0, %(null) ], 48 | [ BigDecimal("0.0") / BigDecimal("0.0"), %(null) ], 49 | [ BigDecimal("2.5"), %("#{BigDecimal('2.5')}") ]] 50 | 51 | StringTests = [[ "this is the ", %("this is the \\u003cstring\\u003e")], 52 | [ 'a "string" with quotes & an ampersand', %("a \\"string\\" with quotes \\u0026 an ampersand") ], 53 | [ "http://test.host/posts/1", %("http://test.host/posts/1")], 54 | [ "Control characters: \x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\u2028\u2029", 55 | %("Control characters: \\u0000\\u0001\\u0002\\u0003\\u0004\\u0005\\u0006\\u0007\\b\\t\\n\\u000b\\f\\r\\u000e\\u000f\\u0010\\u0011\\u0012\\u0013\\u0014\\u0015\\u0016\\u0017\\u0018\\u0019\\u001a\\u001b\\u001c\\u001d\\u001e\\u001f\\u2028\\u2029") ]] 56 | 57 | ArrayTests = [[ ["a", "b", "c"], %([\"a\",\"b\",\"c\"]) ], 58 | [ [1, "a", :b, nil, false], %([1,\"a\",\"b\",null,false]) ]] 59 | 60 | HashTests = [[ { foo: "bar" }, %({\"foo\":\"bar\"}) ], 61 | [ { 1 => 1, 2 => "a", 3 => :b, 4 => nil, 5 => false }, %({\"1\":1,\"2\":\"a\",\"3\":\"b\",\"4\":null,\"5\":false}) ]] 62 | 63 | RangeTests = [[ 1..2, %("1..2")], 64 | [ 1...2, %("1...2")], 65 | [ 1.5..2.5, %("1.5..2.5")]] 66 | 67 | SymbolTests = [[ :a, %("a") ], 68 | [ :this, %("this") ], 69 | [ :"a b", %("a b") ]] 70 | 71 | ObjectTests = [[ Foo.new(1, 2), %({\"a\":1,\"b\":2}) ]] 72 | HashlikeTests = [[ Hashlike.new, %({\"bar\":\"world\",\"foo\":\"hello\"}) ]] 73 | StructTests = [[ MyStruct.new(:foo, "bar"), %({\"name\":\"foo\",\"value\":\"bar\"}) ], 74 | [ MyStruct.new(nil, nil), %({\"name\":null,\"value\":null}) ]] 75 | CustomTests = [[ Custom.new("custom"), '"custom"' ], 76 | [ Custom.new(nil), "null" ], 77 | [ Custom.new(:a), '"a"' ], 78 | [ Custom.new([ :foo, "bar" ]), '["foo","bar"]' ], 79 | [ Custom.new(foo: "hello", bar: "world"), '{"bar":"world","foo":"hello"}' ], 80 | [ Custom.new(Hashlike.new), '{"bar":"world","foo":"hello"}' ], 81 | [ Custom.new(Custom.new(Custom.new(:a))), '"a"' ]] 82 | 83 | RegexpTests = [[ /^a/, '"(?-mix:^a)"' ], [/^\w{1,2}[a-z]+/ix, '"(?ix-m:^\\\\w{1,2}[a-z]+)"']] 84 | 85 | URITests = [[ URI.parse("http://example.com"), %("http://example.com") ]] 86 | 87 | PathnameTests = [[ Pathname.new("lib/index.rb"), %("lib/index.rb") ]] 88 | 89 | DateTests = [[ Date.new(2005, 2, 1), %("2005/02/01") ]] 90 | TimeTests = [[ Time.utc(2005, 2, 1, 15, 15, 10), %("2005/02/01 15:15:10 +0000") ]] 91 | DateTimeTests = [[ DateTime.civil(2005, 2, 1, 15, 15, 10), %("2005/02/01 15:15:10 +0000") ]] 92 | 93 | StandardDateTests = [[ Date.new(2005, 2, 1), %("2005-02-01") ]] 94 | StandardTimeTests = [[ Time.utc(2005, 2, 1, 15, 15, 10), %("2005-02-01T15:15:10.000Z") ]] 95 | StandardDateTimeTests = [[ DateTime.civil(2005, 2, 1, 15, 15, 10), %("2005-02-01T15:15:10.000+00:00") ]] 96 | StandardStringTests = [[ "this is the ", %("this is the ")]] 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/time_zone_test_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TimeZoneTestHelpers 4 | def with_tz_default(tz = nil) 5 | old_tz = Time.zone 6 | Time.zone = tz 7 | yield 8 | ensure 9 | Time.zone = old_tz 10 | end 11 | 12 | def with_env_tz(new_tz = "US/Eastern") 13 | old_tz, ENV["TZ"] = ENV["TZ"], new_tz 14 | yield 15 | ensure 16 | old_tz ? ENV["TZ"] = old_tz : ENV.delete("TZ") 17 | end 18 | 19 | def with_preserve_timezone(value) 20 | old_preserve_tz = ActiveSupport.to_time_preserves_timezone 21 | ActiveSupport.to_time_preserves_timezone = value 22 | yield 23 | ensure 24 | ActiveSupport.to_time_preserves_timezone = old_preserve_tz 25 | end 26 | end 27 | --------------------------------------------------------------------------------