Skip to content

Commit

Permalink
Add an option to decode JSON objects preserving order
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
michalmuskala and Alex Lopez committed Dec 21, 2021
1 parent 1ffe009 commit 7e25ebb
Show file tree
Hide file tree
Showing 7 changed files with 185 additions and 7 deletions.
14 changes: 10 additions & 4 deletions lib/decoder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,14 @@ defmodule Jason.Decoder do
@key 2
@object 3

defrecordp :decode, [keys: nil, strings: nil, floats: nil]
defrecordp :decode, [keys: nil, strings: nil, objects: nil, floats: nil]

def parse(data, opts) when is_binary(data) do
key_decode = key_decode_function(opts)
string_decode = string_decode_function(opts)
float_decode = float_decode_function(opts)
decode = decode(keys: key_decode, strings: string_decode, floats: float_decode)
object_decode = object_decode_function(opts)
decode = decode(keys: key_decode, strings: string_decode, objects: object_decode, floats: float_decode)
try do
value(data, data, 0, [@terminate], decode)
catch
Expand All @@ -71,6 +72,9 @@ defmodule Jason.Decoder do
defp string_decode_function(%{strings: :copy}), do: &:binary.copy/1
defp string_decode_function(%{strings: :reference}), do: &(&1)

defp object_decode_function(%{objects: :maps}), do: &:maps.from_list/1
defp object_decode_function(%{objects: :ordered_objects}), do: &Jason.OrderedObject.new(:lists.reverse(&1))

defp float_decode_function(%{floats: :native}) do
fn string, token, skip ->
try do
Expand Down Expand Up @@ -316,7 +320,8 @@ defmodule Jason.Decoder do
[key, acc | stack] = stack
decode(keys: key_decode) = decode
final = [{key_decode.(key), value} | acc]
continue(rest, original, skip, stack, decode, :maps.from_list(final))
decode(objects: object_decode) = decode
continue(rest, original, skip, stack, decode, object_decode.(final))
_ in ',', rest ->
skip = skip + 1
[key, acc | stack] = stack
Expand All @@ -337,7 +342,8 @@ defmodule Jason.Decoder do
_ in '}', rest ->
case stack do
[[] | stack] ->
continue(rest, original, skip + 1, stack, decode, %{})
decode(objects: object_decode) = decode
continue(rest, original, skip + 1, stack, decode, object_decode.([]))
_ ->
error(original, skip)
end
Expand Down
9 changes: 8 additions & 1 deletion lib/encode.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ defmodule Jason.Encode do

import Bitwise

alias Jason.{Codegen, EncodeError, Encoder, Fragment}
alias Jason.{Codegen, EncodeError, Encoder, Fragment, OrderedObject}

@typep escape :: (String.t, String.t, integer -> iodata)
@typep encode_map :: (map, escape, encode_map -> iodata)
Expand Down Expand Up @@ -233,6 +233,13 @@ defmodule Jason.Encode do
encode.({escape, encode_map})
end

defp struct(value, escape, encode_map, OrderedObject) do
case value do
%{values: []} -> "{}"
%{values: values} -> encode_map.(values, escape, encode_map)
end
end

defp struct(value, escape, encode_map, _module) do
Encoder.encode(value, {escape, encode_map})
end
Expand Down
11 changes: 9 additions & 2 deletions lib/jason.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ defmodule Jason do

@type floats :: :native | :decimals

@type decode_opt :: {:keys, keys} | {:strings, strings} | {:floats, floats}
@type objects :: :maps | :ordered_objects

@type decode_opt :: {:keys, keys} | {:strings, strings} | {:floats, floats} | {:objects, objects}

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

defp format_decode_opts(opts) do
Enum.into(opts, %{keys: :strings, strings: :reference, floats: :native})
Enum.into(opts, %{keys: :strings, strings: :reference, floats: :native, objects: :maps})
end
end
94 changes: 94 additions & 0 deletions lib/ordered_object.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
defmodule Jason.OrderedObject do
@doc """
Struct implementing a JSON object retaining order of properties.
A wrapper around a keyword (that supports non-atom keys) allowing for
proper protocol implementations.
Implements the `Access` behaviour and `Enumerable` protocol with
complexity similar to keywords/lists.
"""

@behaviour Access

@type t :: %__MODULE__{values: [{String.Chars.t(), term()}]}

defstruct values: []

def new(values) when is_list(values) do
%__MODULE__{values: values}
end

@impl Access
def fetch(%__MODULE__{values: values}, key) do
case :lists.keyfind(key, 1, values) do
{_, value} -> {:ok, value}
false -> :error
end
end

@impl Access
def get_and_update(%__MODULE__{values: values} = obj, key, function) do
{result, new_values} = get_and_update(values, [], key, function)
{result, %{obj | values: new_values}}
end

@impl Access
def pop(%__MODULE__{values: values} = obj, key, default \\ nil) do
case :lists.keyfind(key, 1, values) do
{_, value} -> {value, %{obj | values: delete_key(values, key)}}
false -> {default, obj}
end
end

defp get_and_update([{key, current} | t], acc, key, fun) do
case fun.(current) do
{get, value} ->
{get, :lists.reverse(acc, [{key, value} | t])}

:pop ->
{current, :lists.reverse(acc, t)}

other ->
raise "the given function must return a two-element tuple or :pop, got: #{inspect(other)}"
end
end

defp get_and_update([{_, _} = h | t], acc, key, fun), do: get_and_update(t, [h | acc], key, fun)

defp get_and_update([], acc, key, fun) do
case fun.(nil) do
{get, update} ->
{get, [{key, update} | :lists.reverse(acc)]}

:pop ->
{nil, :lists.reverse(acc)}

other ->
raise "the given function must return a two-element tuple or :pop, got: #{inspect(other)}"
end
end

defp delete_key([{key, _} | tail], key), do: delete_key(tail, key)
defp delete_key([{_, _} = pair | tail], key), do: [pair | delete_key(tail, key)]
defp delete_key([], _key), do: []
end

defimpl Enumerable, for: Jason.OrderedObject do
def count(%{values: []}), do: {:ok, 0}
def count(_obj), do: {:error, __MODULE__}

def member?(%{values: []}, _value), do: {:ok, false}
def member?(_obj, _value), do: {:error, __MODULE__}

def slice(%{values: []}), do: {:ok, 0, fn _, _ -> [] end}
def slice(_obj), do: {:error, __MODULE__}

def reduce(%{values: values}, acc, fun), do: Enumerable.List.reduce(values, acc, fun)
end

defimpl Jason.Encoder, for: Jason.OrderedObject do
def encode(%{values: values}, opts) do
Jason.Encode.keyword(values, opts)
end
end
17 changes: 17 additions & 0 deletions test/decode_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,23 @@ defmodule Jason.DecodeTest do
assert parse!(~s({"FOO": "bar"}), keys: &String.downcase/1) == %{"foo" => "bar"}
end

test "decoding objects preserving order" do
import Jason.OrderedObject, only: [new: 1]

assert parse!("{}", objects: :ordered_objects) == new([])
assert parse!(~s({"foo": "bar"}), objects: :ordered_objects) == new([{"foo", "bar"}])

expected = new([{"foo", "bar"}, {"baz", "quux"}])
assert parse!(~s({"foo": "bar", "baz": "quux"}), objects: :ordered_objects) == expected

expected = new([{"foo", new([{"bar", "baz"}])}])
assert parse!(~s({"foo": {"bar": "baz"}}), objects: :ordered_objects) == expected

# Combining with `keys: :atoms`
assert parse!(~s({"foo": "bar"}), keys: :atoms, objects: :ordered_objects) == new([foo: "bar"])
assert parse!(~s({"foo": "bar"}), keys: :atoms!, objects: :ordered_objects) == new([foo: "bar"])
end

test "parsing floats to decimals" do
assert parse!("0.1", floats: :decimals) == Decimal.new("0.1")
assert parse!("-0.1", floats: :decimals) == Decimal.new("-0.1")
Expand Down
17 changes: 17 additions & 0 deletions test/encode_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,23 @@ defmodule Jason.EncoderTest do
assert to_json(decimal) == ~s("1.0")
end

test "OrderedObject" do
import Jason.OrderedObject, only: [new: 1]

assert to_json(new([])) == "{}"
assert to_json(new([{"foo", "bar"}])) == ~s({"foo":"bar"})
assert to_json(new([foo: :bar])) == ~s({"foo":"bar"})
assert to_json(new([{42, :bar}])) == ~s({"42":"bar"})
assert to_json(new([{'foo', :bar}])) == ~s({"foo":"bar"})

multi_key_map = new([{"foo", "foo1"}, {:foo, "foo2"}])
assert_raise EncodeError, "duplicate key: foo", fn ->
to_json(multi_key_map, maps: :strict)
end

assert to_json(multi_key_map) == ~s({"foo":"foo1","foo":"foo2"})
end

defmodule Derived do
@derive Encoder
defstruct name: ""
Expand Down
30 changes: 30 additions & 0 deletions test/ordered_object_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
defmodule Jason.OrderedObjectTest do
use ExUnit.Case, async: true

alias Jason.OrderedObject

test "Access behavior" do
obj = OrderedObject.new([{:foo, 1}, {"bar", 2}])

assert obj[:foo] == 1
assert obj["bar"] == 2

assert Access.pop(obj, :foo) == {1, OrderedObject.new([{"bar", 2}])}

obj = OrderedObject.new(foo: OrderedObject.new(bar: 1))
assert obj[:foo][:bar] == 1
modified_obj = put_in(obj[:foo][:bar], 2)
assert %OrderedObject{} = modified_obj[:foo]
assert modified_obj[:foo][:bar] == 2
end

test "Enumerable protocol" do
obj = OrderedObject.new(foo: 1, bar: 2, quux: 42)

assert Enum.count(obj) == 3
assert Enum.member?(obj, {:foo, 1})

assert Enum.into(obj, %{}) == %{foo: 1, bar: 2, quux: 42}
assert Enum.into(obj, []) == [foo: 1, bar: 2, quux: 42]
end
end

0 comments on commit 7e25ebb

Please sign in to comment.