Skip to content
Merged
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
39 changes: 27 additions & 12 deletions lib/jsonpatch.ex
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,11 @@ 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.

## Examples

iex> source = %{"name" => "Bob", "married" => false, "hobbies" => ["Elixir", "Sport", "Football"]}
Expand All @@ -175,20 +180,30 @@ defmodule Jsonpatch do
%{path: "/hobbies/0", value: "Elixir!", op: "replace"},
%{path: "/age", value: 33, op: "add"}
]

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()) :: [Jsonpatch.t()]
def diff(source, destination)
@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: "")

def diff(%{} = source, %{} = destination) do
do_map_diff(destination, source)
end
cond do
is_map(source) and is_map(destination) ->
do_map_diff(destination, source, opts[:ancestor_path])

def diff(source, destination) when is_list(source) and is_list(destination) do
do_list_diff(destination, source)
end
is_list(source) and is_list(destination) ->
do_list_diff(destination, source, opts[:ancestor_path])

def diff(_, _) do
[]
true ->
[]
end
end

defguardp are_unequal_maps(val1, val2) when val1 != val2 and is_map(val2) and is_map(val1)
Expand All @@ -214,7 +229,7 @@ defmodule Jsonpatch do
patches
end

defp do_map_diff(%{} = destination, %{} = source, ancestor_path \\ "", patches \\ []) do
defp do_map_diff(%{} = destination, %{} = source, ancestor_path, patches \\ []) do
# entrypoint for map diff, let's convert the map to a list of {k, v} tuples
destination
|> Map.to_list()
Expand Down Expand Up @@ -245,7 +260,7 @@ defmodule Jsonpatch do
do_map_diff(rest, source, ancestor_path, patches, [key | checked_keys])
end

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

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

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()}]

@type casted_array_index :: :- | non_neg_integer()
@type casted_object_key :: atom() | String.t()
Expand Down
45 changes: 45 additions & 0 deletions test/jsonpatch_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,51 @@ defmodule JsonpatchTest do
assert Jsonpatch.apply_patch(patches, source, keys: :atoms) == {:ok, destination}
end

test "Create diff with ancestor_path option for nested maps" do
source = %{"a" => 1}
destination = %{"a" => 3}

patches = Jsonpatch.diff(source, destination, ancestor_path: "/nested/object")

assert patches == [
%{op: "replace", path: "/nested/object/a", value: 3}
]
end

test "Create diff with ancestor_path option for nested lists" do
source = [1, 2, 3]
destination = [1, 2, 4]

patches = Jsonpatch.diff(source, destination, ancestor_path: "/items")

assert patches == [
%{op: "replace", path: "/items/2", value: 4}
]
end

test "Create diff with empty ancestor_path (default behavior)" do
source = %{"a" => 1, "b" => 2}
destination = %{"a" => 3, "c" => 4}

patches_with_option = Jsonpatch.diff(source, destination, ancestor_path: "")
patches_without_option = Jsonpatch.diff(source, destination)

assert patches_with_option == patches_without_option
end

test "Create diff with ancestor_path containing escaped characters" do
source = %{"a" => 1}
destination = %{"a" => 2}

patches = Jsonpatch.diff(source, destination, ancestor_path: "/escape~1me~0now")

expected_patches = [
%{op: "replace", path: "/escape~1me~0now/a", value: 2}
]

assert patches == expected_patches
end

defp assert_diff_apply(source, destination) do
patches = Jsonpatch.diff(source, destination)
assert Jsonpatch.apply_patch(patches, source) == {:ok, destination}
Expand Down
Loading