Skip to content

Commit 7e25ebb

Browse files
michalmuskalaAlex Lopez
and
Alex Lopez
committed
Add an option to decode JSON objects preserving order
This adds a new Jason.OrderedObject struct that can be used to store a keyword (with non-atom keys) and properly implement protocols on it. It's further used when the option `objects: :ordered_objects` is provided for the decoder to replace native maps. No performance impact when the option is not used was measured. Co-authored-by: Alex Lopez <[email protected]>
1 parent 1ffe009 commit 7e25ebb

7 files changed

+185
-7
lines changed

lib/decoder.ex

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,14 @@ defmodule Jason.Decoder do
4343
@key 2
4444
@object 3
4545

46-
defrecordp :decode, [keys: nil, strings: nil, floats: nil]
46+
defrecordp :decode, [keys: nil, strings: nil, objects: nil, floats: nil]
4747

4848
def parse(data, opts) when is_binary(data) do
4949
key_decode = key_decode_function(opts)
5050
string_decode = string_decode_function(opts)
5151
float_decode = float_decode_function(opts)
52-
decode = decode(keys: key_decode, strings: string_decode, floats: float_decode)
52+
object_decode = object_decode_function(opts)
53+
decode = decode(keys: key_decode, strings: string_decode, objects: object_decode, floats: float_decode)
5354
try do
5455
value(data, data, 0, [@terminate], decode)
5556
catch
@@ -71,6 +72,9 @@ defmodule Jason.Decoder do
7172
defp string_decode_function(%{strings: :copy}), do: &:binary.copy/1
7273
defp string_decode_function(%{strings: :reference}), do: &(&1)
7374

75+
defp object_decode_function(%{objects: :maps}), do: &:maps.from_list/1
76+
defp object_decode_function(%{objects: :ordered_objects}), do: &Jason.OrderedObject.new(:lists.reverse(&1))
77+
7478
defp float_decode_function(%{floats: :native}) do
7579
fn string, token, skip ->
7680
try do
@@ -316,7 +320,8 @@ defmodule Jason.Decoder do
316320
[key, acc | stack] = stack
317321
decode(keys: key_decode) = decode
318322
final = [{key_decode.(key), value} | acc]
319-
continue(rest, original, skip, stack, decode, :maps.from_list(final))
323+
decode(objects: object_decode) = decode
324+
continue(rest, original, skip, stack, decode, object_decode.(final))
320325
_ in ',', rest ->
321326
skip = skip + 1
322327
[key, acc | stack] = stack
@@ -337,7 +342,8 @@ defmodule Jason.Decoder do
337342
_ in '}', rest ->
338343
case stack do
339344
[[] | stack] ->
340-
continue(rest, original, skip + 1, stack, decode, %{})
345+
decode(objects: object_decode) = decode
346+
continue(rest, original, skip + 1, stack, decode, object_decode.([]))
341347
_ ->
342348
error(original, skip)
343349
end

lib/encode.ex

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ defmodule Jason.Encode do
1818

1919
import Bitwise
2020

21-
alias Jason.{Codegen, EncodeError, Encoder, Fragment}
21+
alias Jason.{Codegen, EncodeError, Encoder, Fragment, OrderedObject}
2222

2323
@typep escape :: (String.t, String.t, integer -> iodata)
2424
@typep encode_map :: (map, escape, encode_map -> iodata)
@@ -233,6 +233,13 @@ defmodule Jason.Encode do
233233
encode.({escape, encode_map})
234234
end
235235

236+
defp struct(value, escape, encode_map, OrderedObject) do
237+
case value do
238+
%{values: []} -> "{}"
239+
%{values: values} -> encode_map.(values, escape, encode_map)
240+
end
241+
end
242+
236243
defp struct(value, escape, encode_map, _module) do
237244
Encoder.encode(value, {escape, encode_map})
238245
end

lib/jason.ex

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ defmodule Jason do
1616

1717
@type floats :: :native | :decimals
1818

19-
@type decode_opt :: {:keys, keys} | {:strings, strings} | {:floats, floats}
19+
@type objects :: :maps | :ordered_objects
20+
21+
@type decode_opt :: {:keys, keys} | {:strings, strings} | {:floats, floats} | {:objects, objects}
2022

2123
@doc """
2224
Parses a JSON value from `input` iodata.
@@ -43,6 +45,11 @@ defmodule Jason do
4345
* `:native` (default) - Native conversion from binary to float using `:erlang.binary_to_float/1`,
4446
* `:decimals` - uses `Decimal.new/1` to parse the binary into a Decimal struct with arbitrary precision.
4547
48+
* `:objects` - controls how objects are decoded. Possible values are:
49+
50+
* `:maps` (default) - objects are decoded as maps
51+
* `:ordered_objects` - objects are decoded as `Jason.OrderedObject` structs
52+
4653
## Decoding keys to atoms
4754
4855
The `:atoms` option uses the `String.to_atom/1` call that can create atoms at runtime.
@@ -230,6 +237,6 @@ defmodule Jason do
230237
end
231238

232239
defp format_decode_opts(opts) do
233-
Enum.into(opts, %{keys: :strings, strings: :reference, floats: :native})
240+
Enum.into(opts, %{keys: :strings, strings: :reference, floats: :native, objects: :maps})
234241
end
235242
end

lib/ordered_object.ex

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
defmodule Jason.OrderedObject do
2+
@doc """
3+
Struct implementing a JSON object retaining order of properties.
4+
5+
A wrapper around a keyword (that supports non-atom keys) allowing for
6+
proper protocol implementations.
7+
8+
Implements the `Access` behaviour and `Enumerable` protocol with
9+
complexity similar to keywords/lists.
10+
"""
11+
12+
@behaviour Access
13+
14+
@type t :: %__MODULE__{values: [{String.Chars.t(), term()}]}
15+
16+
defstruct values: []
17+
18+
def new(values) when is_list(values) do
19+
%__MODULE__{values: values}
20+
end
21+
22+
@impl Access
23+
def fetch(%__MODULE__{values: values}, key) do
24+
case :lists.keyfind(key, 1, values) do
25+
{_, value} -> {:ok, value}
26+
false -> :error
27+
end
28+
end
29+
30+
@impl Access
31+
def get_and_update(%__MODULE__{values: values} = obj, key, function) do
32+
{result, new_values} = get_and_update(values, [], key, function)
33+
{result, %{obj | values: new_values}}
34+
end
35+
36+
@impl Access
37+
def pop(%__MODULE__{values: values} = obj, key, default \\ nil) do
38+
case :lists.keyfind(key, 1, values) do
39+
{_, value} -> {value, %{obj | values: delete_key(values, key)}}
40+
false -> {default, obj}
41+
end
42+
end
43+
44+
defp get_and_update([{key, current} | t], acc, key, fun) do
45+
case fun.(current) do
46+
{get, value} ->
47+
{get, :lists.reverse(acc, [{key, value} | t])}
48+
49+
:pop ->
50+
{current, :lists.reverse(acc, t)}
51+
52+
other ->
53+
raise "the given function must return a two-element tuple or :pop, got: #{inspect(other)}"
54+
end
55+
end
56+
57+
defp get_and_update([{_, _} = h | t], acc, key, fun), do: get_and_update(t, [h | acc], key, fun)
58+
59+
defp get_and_update([], acc, key, fun) do
60+
case fun.(nil) do
61+
{get, update} ->
62+
{get, [{key, update} | :lists.reverse(acc)]}
63+
64+
:pop ->
65+
{nil, :lists.reverse(acc)}
66+
67+
other ->
68+
raise "the given function must return a two-element tuple or :pop, got: #{inspect(other)}"
69+
end
70+
end
71+
72+
defp delete_key([{key, _} | tail], key), do: delete_key(tail, key)
73+
defp delete_key([{_, _} = pair | tail], key), do: [pair | delete_key(tail, key)]
74+
defp delete_key([], _key), do: []
75+
end
76+
77+
defimpl Enumerable, for: Jason.OrderedObject do
78+
def count(%{values: []}), do: {:ok, 0}
79+
def count(_obj), do: {:error, __MODULE__}
80+
81+
def member?(%{values: []}, _value), do: {:ok, false}
82+
def member?(_obj, _value), do: {:error, __MODULE__}
83+
84+
def slice(%{values: []}), do: {:ok, 0, fn _, _ -> [] end}
85+
def slice(_obj), do: {:error, __MODULE__}
86+
87+
def reduce(%{values: values}, acc, fun), do: Enumerable.List.reduce(values, acc, fun)
88+
end
89+
90+
defimpl Jason.Encoder, for: Jason.OrderedObject do
91+
def encode(%{values: values}, opts) do
92+
Jason.Encode.keyword(values, opts)
93+
end
94+
end

test/decode_test.exs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,23 @@ defmodule Jason.DecodeTest do
112112
assert parse!(~s({"FOO": "bar"}), keys: &String.downcase/1) == %{"foo" => "bar"}
113113
end
114114

115+
test "decoding objects preserving order" do
116+
import Jason.OrderedObject, only: [new: 1]
117+
118+
assert parse!("{}", objects: :ordered_objects) == new([])
119+
assert parse!(~s({"foo": "bar"}), objects: :ordered_objects) == new([{"foo", "bar"}])
120+
121+
expected = new([{"foo", "bar"}, {"baz", "quux"}])
122+
assert parse!(~s({"foo": "bar", "baz": "quux"}), objects: :ordered_objects) == expected
123+
124+
expected = new([{"foo", new([{"bar", "baz"}])}])
125+
assert parse!(~s({"foo": {"bar": "baz"}}), objects: :ordered_objects) == expected
126+
127+
# Combining with `keys: :atoms`
128+
assert parse!(~s({"foo": "bar"}), keys: :atoms, objects: :ordered_objects) == new([foo: "bar"])
129+
assert parse!(~s({"foo": "bar"}), keys: :atoms!, objects: :ordered_objects) == new([foo: "bar"])
130+
end
131+
115132
test "parsing floats to decimals" do
116133
assert parse!("0.1", floats: :decimals) == Decimal.new("0.1")
117134
assert parse!("-0.1", floats: :decimals) == Decimal.new("-0.1")

test/encode_test.exs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,23 @@ defmodule Jason.EncoderTest do
9898
assert to_json(decimal) == ~s("1.0")
9999
end
100100

101+
test "OrderedObject" do
102+
import Jason.OrderedObject, only: [new: 1]
103+
104+
assert to_json(new([])) == "{}"
105+
assert to_json(new([{"foo", "bar"}])) == ~s({"foo":"bar"})
106+
assert to_json(new([foo: :bar])) == ~s({"foo":"bar"})
107+
assert to_json(new([{42, :bar}])) == ~s({"42":"bar"})
108+
assert to_json(new([{'foo', :bar}])) == ~s({"foo":"bar"})
109+
110+
multi_key_map = new([{"foo", "foo1"}, {:foo, "foo2"}])
111+
assert_raise EncodeError, "duplicate key: foo", fn ->
112+
to_json(multi_key_map, maps: :strict)
113+
end
114+
115+
assert to_json(multi_key_map) == ~s({"foo":"foo1","foo":"foo2"})
116+
end
117+
101118
defmodule Derived do
102119
@derive Encoder
103120
defstruct name: ""

test/ordered_object_test.exs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
defmodule Jason.OrderedObjectTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Jason.OrderedObject
5+
6+
test "Access behavior" do
7+
obj = OrderedObject.new([{:foo, 1}, {"bar", 2}])
8+
9+
assert obj[:foo] == 1
10+
assert obj["bar"] == 2
11+
12+
assert Access.pop(obj, :foo) == {1, OrderedObject.new([{"bar", 2}])}
13+
14+
obj = OrderedObject.new(foo: OrderedObject.new(bar: 1))
15+
assert obj[:foo][:bar] == 1
16+
modified_obj = put_in(obj[:foo][:bar], 2)
17+
assert %OrderedObject{} = modified_obj[:foo]
18+
assert modified_obj[:foo][:bar] == 2
19+
end
20+
21+
test "Enumerable protocol" do
22+
obj = OrderedObject.new(foo: 1, bar: 2, quux: 42)
23+
24+
assert Enum.count(obj) == 3
25+
assert Enum.member?(obj, {:foo, 1})
26+
27+
assert Enum.into(obj, %{}) == %{foo: 1, bar: 2, quux: 42}
28+
assert Enum.into(obj, []) == [foo: 1, bar: 2, quux: 42]
29+
end
30+
end

0 commit comments

Comments
 (0)