From d30730bf3e9210048259a32c6fd834729c64be49 Mon Sep 17 00:00:00 2001 From: Valian Date: Mon, 21 Jul 2025 12:57:28 +0200 Subject: [PATCH 1/2] Add ancestor_path option to diff function for nested structures Enhance the diff function to accept an `:ancestor_path` option, allowing users to specify a starting point for diffing nested maps and lists. Update related tests to verify functionality with various ancestor paths, including handling of escaped characters. --- lib/jsonpatch.ex | 39 ++++++++++++++++++++++++----------- test/jsonpatch_test.exs | 45 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/lib/jsonpatch.ex b/lib/jsonpatch.ex index 841b0fb..6bf0604 100644 --- a/lib/jsonpatch.ex +++ b/lib/jsonpatch.ex @@ -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"]} @@ -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()) :: [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) @@ -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() @@ -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 diff --git a/test/jsonpatch_test.exs b/test/jsonpatch_test.exs index a3e2a7d..fb0d3b3 100644 --- a/test/jsonpatch_test.exs +++ b/test/jsonpatch_test.exs @@ -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} From 355493212e93e632d27c53883916c0310d84a394 Mon Sep 17 00:00:00 2001 From: Valian Date: Mon, 21 Jul 2025 13:04:15 +0200 Subject: [PATCH 2/2] added proper types for diff opts --- lib/jsonpatch.ex | 2 +- lib/jsonpatch/types.ex | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/jsonpatch.ex b/lib/jsonpatch.ex index 6bf0604..5932c0d 100644 --- a/lib/jsonpatch.ex +++ b/lib/jsonpatch.ex @@ -190,7 +190,7 @@ defmodule Jsonpatch do %{path: "/nested/a", value: 3, op: "replace"} ] """ - @spec diff(Types.json_container(), Types.json_container(), Types.opts()) :: [Jsonpatch.t()] + @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: "") diff --git a/lib/jsonpatch/types.ex b/lib/jsonpatch/types.ex index 52bc226..480803e 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()}] @type casted_array_index :: :- | non_neg_integer() @type casted_object_key :: atom() | String.t()