Skip to content
125 changes: 89 additions & 36 deletions lib/jsonpatch.ex
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,22 @@ defmodule Jsonpatch do
@doc """
Creates a patch from the difference of a source map to a destination map or list.

## Options

* `:ancestor_path` - Sets the initial ancestor path for the diff operation.
Defaults to `""` (root). Useful when you need to diff starting from a nested path.
* `:prepare_map` - A function that lets to customize maps and structs before diffing.
Defaults to `fn map -> map end` (no-op). Useful when you need to customize
how maps and structs are handled during the diff process. Example:

```elixir
fn
%Struct{field1: value1, field2: value2} -> %{field1: "\#{value1} - \#{value2}"}
%OtherStruct{} = struct -> Map.take(struct, [:field1])
map -> map
end
```

## Examples

iex> source = %{"name" => "Bob", "married" => false, "hobbies" => ["Elixir", "Sport", "Football"]}
Expand All @@ -175,53 +191,78 @@ defmodule Jsonpatch do
%{path: "/hobbies/0", value: "Elixir!", op: "replace"},
%{path: "/age", value: 33, op: "add"}
]
"""
@spec diff(Types.json_container(), Types.json_container()) :: [Jsonpatch.t()]
def diff(source, destination)

def diff(%{} = source, %{} = destination) do
do_map_diff(destination, source)
end

def diff(source, destination) when is_list(source) and is_list(destination) do
do_list_diff(destination, source)
end

def diff(_, _) do
[]
iex> source = %{"a" => 1, "b" => 2}
iex> destination = %{"a" => 3, "c" => 4}
iex> Jsonpatch.diff(source, destination, ancestor_path: "/nested")
[
%{path: "/nested/b", op: "remove"},
%{path: "/nested/c", value: 4, op: "add"},
%{path: "/nested/a", value: 3, op: "replace"}
]
"""
@spec diff(Types.json_container(), Types.json_container(), Types.opts_diff()) :: [Jsonpatch.t()]
def diff(source, destination, opts \\ []) do
opts =
Keyword.validate!(opts,
ancestor_path: "",
# by default, a no-op
prepare_map: fn map -> map end
)

cond do
is_map(source) and is_map(destination) ->
do_map_diff(destination, source, opts[:ancestor_path], [], opts)

is_list(source) and is_list(destination) ->
do_list_diff(destination, source, opts[:ancestor_path], [], 0, opts)

# type of value changed, eg set to nil
source != destination ->
destination = maybe_prepare_map(destination, opts)
[%{op: "replace", path: opts[:ancestor_path], value: destination}]

true ->
[]
end
end

defguardp are_unequal_maps(val1, val2) when val1 != val2 and is_map(val2) and is_map(val1)
defguardp are_unequal_lists(val1, val2) when val1 != val2 and is_list(val2) and is_list(val1)

defp do_diff(dest, source, path, key, patches) when are_unequal_lists(dest, source) do
defp do_diff(dest, source, path, key, patches, opts) when are_unequal_lists(dest, source) do
# uneqal lists, let's use a specialized function for that
do_list_diff(dest, source, "#{path}/#{escape(key)}", patches)
do_list_diff(dest, source, "#{path}/#{escape(key)}", patches, 0, opts)
end

defp do_diff(dest, source, path, key, patches) when are_unequal_maps(dest, source) do
defp do_diff(dest, source, path, key, patches, opts) when are_unequal_maps(dest, source) do
# uneqal maps, let's use a specialized function for that
do_map_diff(dest, source, "#{path}/#{escape(key)}", patches)
do_map_diff(dest, source, "#{path}/#{escape(key)}", patches, opts)
end

defp do_diff(dest, source, path, key, patches) when dest != source do
defp do_diff(dest, source, path, key, patches, opts) when dest != source do
# scalar values or change of type (map -> list etc), let's just make a replace patch
[%{op: "replace", path: "#{path}/#{escape(key)}", value: dest} | patches]
value = maybe_prepare_map(dest, opts)
[%{op: "replace", path: "#{path}/#{escape(key)}", value: value} | patches]
end

defp do_diff(_dest, _source, _path, _key, patches) do
defp do_diff(_dest, _source, _path, _key, patches, _opts) do
# no changes, return patches as is
patches
end

defp do_map_diff(%{} = destination, %{} = source, ancestor_path \\ "", patches \\ []) do
defp do_map_diff(%{} = destination, %{} = source, ancestor_path, patches, opts) do
# Convert structs to maps if prepare_map function is provided
destination = maybe_prepare_map(destination, opts)
source = maybe_prepare_map(source, opts)

# entrypoint for map diff, let's convert the map to a list of {k, v} tuples
destination
|> Map.to_list()
|> do_map_diff(source, ancestor_path, patches, [])
|> do_map_diff(source, ancestor_path, patches, [], opts)
end

defp do_map_diff([], source, ancestor_path, patches, checked_keys) do
defp do_map_diff([], source, ancestor_path, patches, checked_keys, _opts) do
# The complete desination was check. Every key that is not in the list of
# checked keys, must be removed.
Enum.reduce(source, patches, fn {k, _}, patches ->
Expand All @@ -233,44 +274,56 @@ defmodule Jsonpatch do
end)
end

defp do_map_diff([{key, val} | rest], source, ancestor_path, patches, checked_keys) do
defp do_map_diff([{key, val} | rest], source, ancestor_path, patches, checked_keys, opts) do
# normal iteration through list of map {k, v} tuples. We track seen keys to later remove not seen keys.
patches =
case Map.fetch(source, key) do
{:ok, source_val} -> do_diff(val, source_val, ancestor_path, key, patches)
:error -> [%{op: "add", path: "#{ancestor_path}/#{escape(key)}", value: val} | patches]
{:ok, source_val} ->
do_diff(val, source_val, ancestor_path, key, patches, opts)

:error ->
value = maybe_prepare_map(val, opts)
[%{op: "add", path: "#{ancestor_path}/#{escape(key)}", value: value} | patches]
end

# Diff next value of same level
do_map_diff(rest, source, ancestor_path, patches, [key | checked_keys])
do_map_diff(rest, source, ancestor_path, patches, [key | checked_keys], opts)
end

defp do_list_diff(destination, source, ancestor_path \\ "", patches \\ [], idx \\ 0)
defp do_list_diff(destination, source, ancestor_path, patches, idx, opts)

defp do_list_diff([], [], _path, patches, _idx), do: patches
defp do_list_diff([], [], _path, patches, _idx, _opts), do: patches

defp do_list_diff([], [_item | source_rest], ancestor_path, patches, idx) do
defp do_list_diff([], [_item | source_rest], ancestor_path, patches, idx, opts) do
# if we find any leftover items in source, we have to remove them
patches = [%{op: "remove", path: "#{ancestor_path}/#{idx}"} | patches]
do_list_diff([], source_rest, ancestor_path, patches, idx + 1)
do_list_diff([], source_rest, ancestor_path, patches, idx + 1, opts)
end

defp do_list_diff(items, [], ancestor_path, patches, idx) do
defp do_list_diff(items, [], ancestor_path, patches, idx, opts) do
# we have to do it without recursion, because we have to keep the order of the items
items
|> Enum.map_reduce(idx, fn val, idx ->
{%{op: "add", path: "#{ancestor_path}/#{idx}", value: val}, idx + 1}
{%{op: "add", path: "#{ancestor_path}/#{idx}", value: maybe_prepare_map(val, opts)},
idx + 1}
end)
|> elem(0)
|> Kernel.++(patches)
end

defp do_list_diff([val | rest], [source_val | source_rest], ancestor_path, patches, idx) do
defp do_list_diff([val | rest], [source_val | source_rest], ancestor_path, patches, idx, opts) do
# case when there's an item in both desitation and source. Let's just compare them
patches = do_diff(val, source_val, ancestor_path, idx, patches)
do_list_diff(rest, source_rest, ancestor_path, patches, idx + 1)
patches = do_diff(val, source_val, ancestor_path, idx, patches, opts)
do_list_diff(rest, source_rest, ancestor_path, patches, idx + 1, opts)
end

defp maybe_prepare_map(value, opts) when is_map(value) do
prepare_fn = Keyword.fetch!(opts, :prepare_map)
prepare_fn.(value)
end

defp maybe_prepare_map(value, _opts), do: value

@compile {:inline, escape: 1}

defp escape(fragment) when is_binary(fragment) do
Expand Down
1 change: 1 addition & 0 deletions lib/jsonpatch/types.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ defmodule Jsonpatch.Types do
- `:keys` - controls how path fragments are decoded.
"""
@type opts :: [{:keys, opt_keys()}]
@type opts_diff :: [{:ancestor_path, String.t()} | {:prepare_map, (struct() | map() -> map())}]

@type casted_array_index :: :- | non_neg_integer()
@type casted_object_key :: atom() | String.t()
Expand Down
Loading
Loading