Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
44 changes: 44 additions & 0 deletions ext/oj/oj.c
Original file line number Diff line number Diff line change
Expand Up @@ -1663,6 +1663,39 @@ static VALUE safe_load(VALUE self, VALUE doc) {
* - *io* [_IO__|_String_] IO Object to read from
*/

// MAX_INDENT spaces. oj_parse_options() has already raised on an Integer
// :indent above MAX_INDENT by the time stringify_indent_option() runs, so a
// String indent is always a prefix of this.
static const char indent_spaces[] = " ";

// 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. A negative count
// becomes an empty String, which is how Oj itself indents with it.
static VALUE stringify_indent_option(VALUE ropts) {
VALUE indent = rb_hash_lookup2(ropts, oj_indent_sym, Qundef);
VALUE copy;
long cnt;

if (Qundef == indent || !FIXNUM_P(indent)) {
return ropts;
}
cnt = FIX2LONG(indent);
if (0 > cnt) {
cnt = 0;
} else if ((long)sizeof(indent_spaces) - 1 < cnt) {
cnt = sizeof(indent_spaces) - 1;
}
copy = rb_hash_dup(ropts);
rb_hash_aset(copy, oj_indent_sym, rb_str_new(indent_spaces, cnt));

return copy;
}

struct dump_arg {
struct _out *out;
struct _options *copts;
Expand Down Expand Up @@ -1703,6 +1736,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 +1754,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
70 changes: 70 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,67 @@ 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_max_as_spaces
keeper = ArgKeeper.new
Oj.dump(keeper, :mode => :compat, :use_to_json => true, :indent => 16)
assert_equal(' ' * 16, keeper.args[0][:indent])
end

# Oj treats a negative indent as no indent.
def test_dump_to_json_args_indent_negative_as_empty_string
keeper = ArgKeeper.new
Oj.dump(keeper, :mode => :compat, :use_to_json => true, :indent => -2)
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