Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# CHANGELOG

## 3.17.7 - unreleased

- Fixed issue #1105, where `Oj.dump` with an Integer `:indent` in compat or custom mode with `use_to_json` raised `TypeError` from the json gem, whose `indent` is a String, for any object whose `to_json` comes from the json gem. Nested values reach `to_json` even without `use_to_json`, so a `Time` inside an Array failed the same way. The hash `to_json` receives now carries the indent as that many spaces. The caller's hash is not modified.

## 3.17.6 - 2026-08-10

- Fixed issue #1092, where the encoder ActiveSupport 8.1 caches with `escape: false` froze the options as they stood before `set_encoder` wrote `time_precision` into them, so `to_json(escape: false)` emitted 9 fractional digits. An options hash that names no option Oj knows no longer detaches an encoder from the defaults. (#1093)
Expand Down
39 changes: 39 additions & 0 deletions ext/oj/oj.c
Original file line number Diff line number Diff line change
Expand Up @@ -1663,6 +1663,34 @@ static VALUE safe_load(VALUE self, VALUE doc) {
* - *io* [_IO__|_String_] IO Object to read from
*/

// Oj's :indent is an Integer count of spaces while the json gem's is the
// String to indent with. Oj.dump() forwards its options hash to the to_json
// methods it calls in compat and custom mode, and the json gem's generator
// raises TypeError on an Integer indent, so a Time or any other object whose
// to_json comes from the json gem could not be dumped with indent: 2. Return
// ropts itself unless its :indent is an Integer, and otherwise a copy of it
// with :indent as that many spaces. ropts is never modified.
static VALUE stringify_indent_option(VALUE ropts) {
VALUE indent = rb_hash_lookup2(ropts, oj_indent_sym, Qundef);
VALUE copy;
VALUE str;
long cnt;

if (Qundef == indent || !FIXNUM_P(indent)) {
return ropts;
}
cnt = FIX2LONG(indent);
if (0 > cnt) {
cnt = 0;
}
str = rb_str_new(NULL, cnt);
memset(RSTRING_PTR(str), ' ', cnt);
Comment thread
ohler55 marked this conversation as resolved.
Outdated
copy = rb_hash_dup(ropts);
rb_hash_aset(copy, oj_indent_sym, str);

return copy;
}

struct dump_arg {
struct _out *out;
struct _options *copts;
Expand Down Expand Up @@ -1703,6 +1731,7 @@ static VALUE dump(int argc, VALUE *argv, VALUE self) {
struct dump_arg arg;
struct _out out;
struct _options copts = oj_default_options;
VALUE to_json_argv[2];

if (1 > argc) {
rb_raise(rb_eArgError, "wrong number of arguments (0 for 1).");
Expand All @@ -1720,6 +1749,16 @@ static VALUE dump(int argc, VALUE *argv, VALUE self) {
arg.copts = &copts;
arg.argc = argc;
arg.argv = argv;
// The options hash is also what to_json receives, so hand it an indent
// in the form the json gem takes. Compat mode hands every nested value
// to to_json whether or not use_to_json is set, so the check is on the
// mode rather than on the flag. Only the modes that call to_json pay for
// the copy.
if (2 == argc && (CompatMode == copts.mode || CustomMode == copts.mode) && T_HASH == rb_type(argv[1])) {
Comment thread
ohler55 marked this conversation as resolved.
to_json_argv[0] = argv[0];
to_json_argv[1] = stringify_indent_option(argv[1]);
arg.argv = to_json_argv;
}

oj_out_init(arg.out);

Expand Down
57 changes: 57 additions & 0 deletions test/test_compat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ def to_json(*a)
end
end # Argy

class ArgKeeper
attr_reader :args

def to_json(*a)
@args = a
'{}'
end
end # ArgKeeper

class Rex
attr_accessor :s

Expand Down Expand Up @@ -573,6 +582,54 @@ def test_arg_passing
assert_match(/.*max_nesting.*40.*/, json)
end

# Oj.dump forwards its options hash to to_json. Oj's :indent is an Integer
# while the json gem's is a String, and the json gem raises TypeError on an
# Integer, so to_json has to receive the indent as a String of spaces.
def test_dump_to_json_args_indent_as_string
keeper = ArgKeeper.new
opts = { :mode => :compat, :use_to_json => true, :indent => 2 }
Oj.dump(keeper, opts)
assert_equal(' ', keeper.args[0][:indent])
# The caller's hash is left alone.
assert_equal(2, opts[:indent])
end

def test_dump_to_json_args_indent_zero_as_empty_string
keeper = ArgKeeper.new
Oj.dump(keeper, :mode => :compat, :use_to_json => true, :indent => 0)
assert_equal('', keeper.args[0][:indent])
end

def test_dump_to_json_args_indent_string_untouched
keeper = ArgKeeper.new
Oj.dump(keeper, :mode => :compat, :use_to_json => true, :indent => "\t")
assert_equal("\t", keeper.args[0][:indent])
end

def test_dump_to_json_args_same_hash_without_integer_indent
keeper = ArgKeeper.new
opts = { :mode => :compat, :use_to_json => true, :indent => nil }
Oj.dump(keeper, opts)
assert_same(opts, keeper.args[0])
opts = { :mode => :compat, :use_to_json => true }
Oj.dump(keeper, opts)
assert_same(opts, keeper.args[0])
end

# Nested values are handed to to_json whether or not use_to_json is set,
# so the indent they receive has to be converted as well.
def test_dump_to_json_args_indent_nested_without_use_to_json
keeper = ArgKeeper.new
Oj.dump([keeper], :mode => :compat, :indent => 2)
assert_equal(' ', keeper.args[0][:indent])
end

def test_dump_to_json_args_indent_custom_mode
keeper = ArgKeeper.new
Oj.dump(keeper, :mode => :custom, :use_to_json => true, :indent => 2)
assert_equal(' ', keeper.args[0][:indent])
end

def test_max_nesting
assert_raises() { Oj.to_json([[[[[]]]]], :max_nesting => 3) }
assert_raises() { Oj.dump([[[[[]]]]], :max_nesting => 3, :mode=>:compat) }
Expand Down
60 changes: 60 additions & 0 deletions test/test_to_json_json_gem.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

# The json gem is loaded before Oj on purpose. Time, Date, Symbol, and the
# other core classes then answer to_json with the json gem's generator, which
# is what Oj.dump calls in compat mode with use_to_json. Kept in its own file
# because loading the json gem changes what every other compat test would
# dump.

$LOAD_PATH << __dir__
@oj_dir = File.dirname(File.expand_path(__dir__))
%w(lib ext).each do |dir|
$LOAD_PATH << File.join(@oj_dir, dir)
end

require 'minitest'
require 'minitest/autorun'
require 'json'
require 'oj'

class ToJsonJsonGemJuice < Minitest::Test

def setup
@default_options = Oj.default_options
# Set here rather than per call so the hash Oj.dump forwards to to_json
# holds only the keys under test.
Oj.default_options = { :mode => :compat, :use_to_json => true }
end

def teardown
Oj.default_options = @default_options
end

# The json gem's indent is a String. Oj's Integer indent must reach it as
# that many spaces.
def test_dump_time_integer_indent
t = Time.at(1_355_218_745).utc
assert_equal('"2012-12-11 09:39:05 UTC"', Oj.dump(t, :indent => 2))
end

def test_dump_time_string_indent
t = Time.at(1_355_218_745).utc
assert_equal('"2012-12-11 09:39:05 UTC"', Oj.dump(t, :indent => ' '))
end

# use_to_json only gates the top level value. Nested values always reach
# to_json, so a Time inside an Array raised even without the option.
def test_dump_nested_time_integer_indent_without_use_to_json
Oj.default_options = { :mode => :compat, :use_to_json => false }
t = Time.at(1_355_218_745).utc
json = Oj.dump([t], :indent => 2)
assert_equal(%|[\n "2012-12-11 09:39:05 UTC"\n]\n|, json)
end

def test_dump_nested_time_integer_indent
t = Time.at(1_355_218_745).utc
json = Oj.dump({ 'at' => t }, :indent => 2)
assert_equal(%|{\n "at":"2012-12-11 09:39:05 UTC"\n}\n|, json)
end
end
Loading