diff --git a/lib/jsonpatch.ex b/lib/jsonpatch.ex index 841b0fb..d685123 100644 --- a/lib/jsonpatch.ex +++ b/lib/jsonpatch.ex @@ -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"]} @@ -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 -> @@ -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 diff --git a/lib/jsonpatch/types.ex b/lib/jsonpatch/types.ex index 52bc226..229f06e 100644 --- a/lib/jsonpatch/types.ex +++ b/lib/jsonpatch/types.ex @@ -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() diff --git a/test/jsonpatch_test.exs b/test/jsonpatch_test.exs index a3e2a7d..24bf95c 100644 --- a/test/jsonpatch_test.exs +++ b/test/jsonpatch_test.exs @@ -3,6 +3,10 @@ defmodule JsonpatchTest do doctest Jsonpatch + defmodule TestStruct do + defstruct [:field1, :field2, :inner, :field] + end + test "Create diff from list and apply it" do # Arrange source = [1, 2, %{"drei" => 3}, 5, 6] @@ -53,8 +57,8 @@ defmodule JsonpatchTest do assert [] = Jsonpatch.diff(source, destination) end - test "Create no diff on unexpected input" do - assert [] = Jsonpatch.diff("unexpected", 1) + test "Create full replace operation when type of root value changes" do + assert [%{op: "replace", path: "", value: 1}] = Jsonpatch.diff("unexpected", 1) end test "A.4. Removing an Array Element" do @@ -140,6 +144,201 @@ 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") + + assert patches == [ + %{op: "replace", path: "/escape~1me~0now/a", value: 2} + ] + end + + test "Create diff with prepare_map option using subset of fields" do + source = %TestStruct{ + field1: "value1", + field2: "value2", + inner: %{nested: "old"}, + field: "ignored" + } + + destination = %TestStruct{ + field1: "new_value", + field2: "value2", + inner: %{nested: "new"}, + field: "also_ignored" + } + + patches = + Jsonpatch.diff(source, destination, + prepare_map: fn + %TestStruct{field1: field1, inner: inner} -> %{field1: field1, inner: inner} + map -> map + end + ) + + expected_patches = [ + %{op: "replace", path: "/field1", value: "new_value"}, + %{op: "replace", path: "/inner/nested", value: "new"} + ] + + assert_equal_patches(patches, expected_patches) + end + + test "Create diff with prepare_map option using dynamic field creation" do + source = %TestStruct{ + field1: "hello", + field2: "world" + } + + destination = %TestStruct{ + field1: "hi", + field2: "world" + } + + patches = + Jsonpatch.diff(source, destination, + prepare_map: &%{field3: "#{&1.field1} - #{&1.field2}"} + ) + + expected_patches = [ + %{op: "replace", path: "/field3", value: "hi - world"} + ] + + assert_equal_patches(patches, expected_patches) + end + + test "Create diff with prepare_map option using nested dynamic field creation" do + source = %TestStruct{ + field1: "hello", + field2: "world", + inner: %TestStruct{field1: "nested", field2: "old"} + } + + destination = %TestStruct{ + field1: "hi", + field2: "world", + inner: %TestStruct{field1: "nested", field2: "new"} + } + + patches = + Jsonpatch.diff(source, destination, + prepare_map: &%{inner: &1.inner, field3: "#{&1.field1} - #{&1.field2}"} + ) + + expected_patches = [ + %{op: "replace", path: "/field3", value: "hi - world"}, + %{op: "replace", path: "/inner/field3", value: "nested - new"} + ] + + assert_equal_patches(patches, expected_patches) + end + + test "add map patches are correctly processed by prepare_map" do + source = %{} + + destination = %{ + a: %TestStruct{ + field1: "hi", + field2: "world" + } + } + + patches = + Jsonpatch.diff(source, destination, + prepare_map: fn + %TestStruct{field1: field1} -> %{field1: field1} + map -> map + end + ) + + assert patches == [ + %{op: "add", path: "/a", value: %{field1: "hi"}} + ] + end + + test "add list patches are correctly processed by prepare_map" do + source = [] + + destination = [ + %TestStruct{ + field1: "hi", + field2: "world" + } + ] + + patches = Jsonpatch.diff(source, destination, prepare_map: &%{field1: &1.field1}) + + assert patches == [ + %{op: "add", path: "/0", value: %{field1: "hi"}} + ] + end + + test "replace map patches are correctly processed by prepare_map" do + source = %{"a" => "test"} + destination = %{"a" => %TestStruct{field1: "old"}} + + patches = + Jsonpatch.diff(source, destination, + prepare_map: fn + %TestStruct{field1: field1} -> %{field1: field1} + map -> map + end + ) + + assert patches == [ + %{op: "replace", path: "/a", value: %{field1: "old"}} + ] + end + + test "Create diff with ancestor_path when changing type of base value (map to nil)" do + source = %{"key" => "value"} + destination = nil + + patches = Jsonpatch.diff(source, destination, ancestor_path: "/nested") + + # This should fail for now - the diff should not handle type changes with ancestor_path + # The expected behavior would be to generate a replace operation for the entire data object + expected_patches = [ + %{op: "replace", path: "/nested", value: nil} + ] + + 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} @@ -285,18 +484,14 @@ defmodule JsonpatchTest do }} = Jsonpatch.apply_patch(patch, target, keys: {:custom, convert_fn}) end - defmodule TestStruct do - defstruct [:field] - end - test "struct are just maps" do - patch = %Jsonpatch.Operation.Replace{path: "/a/field/c", value: 1} - target = %{a: %TestStruct{field: %{c: 0}}} + patch = %Jsonpatch.Operation.Replace{path: "/a/field1/c", value: 1} + target = %{a: %TestStruct{field1: %{c: 0}}} patched = Jsonpatch.apply_patch!(patch, target, keys: :atoms) - assert %{a: %TestStruct{field: %{c: 1}}} = patched + assert %{a: %TestStruct{field1: %{c: 1}}} = patched - patch = %Jsonpatch.Operation.Remove{path: "/a/field"} - target = %{a: %TestStruct{field: %{c: 0}}} + patch = %Jsonpatch.Operation.Remove{path: "/a/field1"} + target = %{a: %TestStruct{field1: %{c: 0}}} patched = Jsonpatch.apply_patch!(patch, target, keys: :atoms) assert %{a: %{__struct__: TestStruct}} = patched end @@ -447,6 +642,10 @@ defmodule JsonpatchTest do end end + defp assert_equal_patches(patches1, patches2) do + assert Enum.sort_by(patches1, & &1.path) == Enum.sort_by(patches2, & &1.path) + end + defp string_to_existing_atom(data) when is_binary(data) do {:ok, String.to_existing_atom(data)} rescue