From d30730bf3e9210048259a32c6fd834729c64be49 Mon Sep 17 00:00:00 2001 From: Valian Date: Mon, 21 Jul 2025 12:57:28 +0200 Subject: [PATCH 1/9] 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/9] 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() From 03b4d5e69bf64a279999318dbfb7e574fdc54682 Mon Sep 17 00:00:00 2001 From: Valian Date: Mon, 21 Jul 2025 13:30:59 +0200 Subject: [PATCH 3/9] Enhance diff function to support prepare_struct option for custom struct handling Updated the diff function to accept a `:prepare_struct` option, allowing users to define a function for converting structs to maps. This change includes modifications to related functions and tests to ensure proper functionality with various struct scenarios. --- lib/jsonpatch.ex | 61 +++++++++++++++++---------- lib/jsonpatch/types.ex | 2 +- test/jsonpatch_test.exs | 93 +++++++++++++++++++++++++++++++++++------ 3 files changed, 119 insertions(+), 37 deletions(-) diff --git a/lib/jsonpatch.ex b/lib/jsonpatch.ex index 5932c0d..9f5b059 100644 --- a/lib/jsonpatch.ex +++ b/lib/jsonpatch.ex @@ -192,14 +192,18 @@ defmodule Jsonpatch do """ @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: "") + opts = + Keyword.validate!(opts, + ancestor_path: "", + prepare_struct: fn struct -> Map.from_struct(struct) end + ) cond do is_map(source) and is_map(destination) -> - do_map_diff(destination, source, opts[:ancestor_path]) + do_map_diff(destination, source, opts[:ancestor_path], [], opts) is_list(source) and is_list(destination) -> - do_list_diff(destination, source, opts[:ancestor_path]) + do_list_diff(destination, source, opts[:ancestor_path], [], 0, opts) true -> [] @@ -209,34 +213,38 @@ defmodule Jsonpatch do 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] 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_struct function is provided + destination = maybe_prepare_struct(destination, opts) + source = maybe_prepare_struct(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 -> @@ -248,29 +256,29 @@ 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) + {:ok, source_val} -> do_diff(val, source_val, ancestor_path, key, patches, opts) :error -> [%{op: "add", path: "#{ancestor_path}/#{escape(key)}", value: val} | 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 -> @@ -280,12 +288,19 @@ defmodule Jsonpatch do |> 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_struct(value, opts) when is_struct(value) do + prepare_fn = Keyword.fetch!(opts, :prepare_struct) + prepare_fn.(value) end + defp maybe_prepare_struct(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 480803e..2ad9ee3 100644 --- a/lib/jsonpatch/types.ex +++ b/lib/jsonpatch/types.ex @@ -32,7 +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 opts_diff :: [{:ancestor_path, String.t()} | {:prepare_struct, (struct() -> 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 fb0d3b3..42dfb8a 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] @@ -178,11 +182,78 @@ defmodule JsonpatchTest do patches = Jsonpatch.diff(source, destination, ancestor_path: "/escape~1me~0now") - expected_patches = [ - %{op: "replace", path: "/escape~1me~0now/a", value: 2} - ] + assert patches == [ + %{op: "replace", path: "/escape~1me~0now/a", value: 2} + ] + end + + test "Create diff with prepare_struct option using subset of fields" do + source = %TestStruct{ + field1: "value1", + field2: "value2", + inner: %{nested: "old"}, + field: "ignored" + } - assert patches == expected_patches + destination = %TestStruct{ + field1: "new_value", + field2: "value2", + inner: %{nested: "new"}, + field: "also_ignored" + } + + patches = + Jsonpatch.diff(source, destination, prepare_struct: &Map.take(&1, [:field1, :inner])) + + assert patches == [ + %{op: "replace", path: "/field1", value: "new_value"}, + %{op: "replace", path: "/inner/nested", value: "new"} + ] + end + + test "Create diff with prepare_struct option using dynamic field creation" do + source = %TestStruct{ + field1: "hello", + field2: "world" + } + + destination = %TestStruct{ + field1: "hi", + field2: "world" + } + + patches = + Jsonpatch.diff(source, destination, + prepare_struct: &%{field3: "#{&1.field1} - #{&1.field2}"} + ) + + assert patches == [ + %{op: "replace", path: "/field3", value: "hi - world"} + ] + end + + test "Create diff with prepare_struct 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_struct: &%{inner: &1.inner, field3: "#{&1.field1} - #{&1.field2}"} + ) + + assert patches == [ + %{op: "replace", path: "/field3", value: "hi - world"}, + %{op: "replace", path: "/inner/field3", value: "nested - new"} + ] end defp assert_diff_apply(source, destination) do @@ -330,18 +401,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 From 06036f4430f49280bc4c752befe63c023a246db0 Mon Sep 17 00:00:00 2001 From: Valian Date: Mon, 21 Jul 2025 13:41:17 +0200 Subject: [PATCH 4/9] Refactor tests to use assert_equal_patches for patch validation --- test/jsonpatch_test.exs | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/test/jsonpatch_test.exs b/test/jsonpatch_test.exs index 42dfb8a..649febf 100644 --- a/test/jsonpatch_test.exs +++ b/test/jsonpatch_test.exs @@ -205,10 +205,12 @@ defmodule JsonpatchTest do patches = Jsonpatch.diff(source, destination, prepare_struct: &Map.take(&1, [:field1, :inner])) - assert patches == [ - %{op: "replace", path: "/field1", value: "new_value"}, - %{op: "replace", path: "/inner/nested", value: "new"} - ] + 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_struct option using dynamic field creation" do @@ -227,9 +229,11 @@ defmodule JsonpatchTest do prepare_struct: &%{field3: "#{&1.field1} - #{&1.field2}"} ) - assert patches == [ - %{op: "replace", path: "/field3", value: "hi - world"} - ] + expected_patches = [ + %{op: "replace", path: "/field3", value: "hi - world"} + ] + + assert_equal_patches(patches, expected_patches) end test "Create diff with prepare_struct option using nested dynamic field creation" do @@ -250,10 +254,12 @@ defmodule JsonpatchTest do prepare_struct: &%{inner: &1.inner, field3: "#{&1.field1} - #{&1.field2}"} ) - assert patches == [ - %{op: "replace", path: "/field3", value: "hi - world"}, - %{op: "replace", path: "/inner/field3", value: "nested - new"} - ] + expected_patches = [ + %{op: "replace", path: "/field3", value: "hi - world"}, + %{op: "replace", path: "/inner/field3", value: "nested - new"} + ] + + assert_equal_patches(patches, expected_patches) end defp assert_diff_apply(source, destination) do @@ -559,6 +565,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 From fa67c8f3ee5ad2e998906fa0c6ca9755d06d1e6e Mon Sep 17 00:00:00 2001 From: Valian Date: Mon, 21 Jul 2025 13:41:37 +0200 Subject: [PATCH 5/9] Document the :prepare_struct option in the diff function for custom struct handling Added detailed documentation for the `:prepare_struct` option, which allows users to define a function for converting structs to maps during the diff process. Updated the default behavior to a no-op function for clarity. --- lib/jsonpatch.ex | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/jsonpatch.ex b/lib/jsonpatch.ex index 9f5b059..71bc7c4 100644 --- a/lib/jsonpatch.ex +++ b/lib/jsonpatch.ex @@ -167,6 +167,17 @@ defmodule Jsonpatch do * `: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_struct` - A function that converts structs to maps before diffing. + Defaults to `fn struct -> struct end` (no-op). Useful when you need to customize + how structs are handled during the diff process. Example: + + ```elixir + fn + %Struct{field1: value1, field2: value2} -> %{field1: "\#{value1} - \#{value2}"} + %OtherStruct{} = struct -> Map.take(struct, [:field1]) + struct -> struct + end + ``` ## Examples @@ -195,7 +206,8 @@ defmodule Jsonpatch do opts = Keyword.validate!(opts, ancestor_path: "", - prepare_struct: fn struct -> Map.from_struct(struct) end + # by default, a no-op + prepare_struct: fn struct -> struct end ) cond do From 279dd6b222b348810919035b5be2bd302d275413 Mon Sep 17 00:00:00 2001 From: Valian Date: Tue, 22 Jul 2025 11:28:10 +0200 Subject: [PATCH 6/9] properly processing add and replace patches --- lib/jsonpatch.ex | 18 +++++++++++------ test/jsonpatch_test.exs | 45 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/lib/jsonpatch.ex b/lib/jsonpatch.ex index 71bc7c4..d84824a 100644 --- a/lib/jsonpatch.ex +++ b/lib/jsonpatch.ex @@ -235,9 +235,10 @@ defmodule Jsonpatch do do_map_diff(dest, source, "#{path}/#{escape(key)}", patches, opts) end - defp do_diff(dest, source, path, key, patches, _opts) 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_struct(dest, opts) + [%{op: "replace", path: "#{path}/#{escape(key)}", value: value} | patches] end defp do_diff(_dest, _source, _path, _key, patches, _opts) do @@ -272,8 +273,12 @@ defmodule Jsonpatch 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, opts) - :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_struct(val, opts) + [%{op: "add", path: "#{ancestor_path}/#{escape(key)}", value: value} | patches] end # Diff next value of same level @@ -290,11 +295,12 @@ defmodule Jsonpatch do do_list_diff([], source_rest, ancestor_path, patches, idx + 1, opts) end - defp do_list_diff(items, [], ancestor_path, patches, idx, _opts) 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_struct(val, opts)}, + idx + 1} end) |> elem(0) |> Kernel.++(patches) diff --git a/test/jsonpatch_test.exs b/test/jsonpatch_test.exs index 649febf..b5e4e82 100644 --- a/test/jsonpatch_test.exs +++ b/test/jsonpatch_test.exs @@ -262,6 +262,51 @@ defmodule JsonpatchTest do assert_equal_patches(patches, expected_patches) end + test "add map patches are correctly processed by prepare_struct" do + source = %{} + + destination = %{ + a: %TestStruct{ + field1: "hi", + field2: "world" + } + } + + patches = Jsonpatch.diff(source, destination, prepare_struct: &%{field1: &1.field1}) + + assert patches == [ + %{op: "add", path: "/a", value: %{field1: "hi"}} + ] + end + + test "add list patches are correctly processed by prepare_struct" do + source = [] + + destination = [ + %TestStruct{ + field1: "hi", + field2: "world" + } + ] + + patches = Jsonpatch.diff(source, destination, prepare_struct: &%{field1: &1.field1}) + + assert patches == [ + %{op: "add", path: "/0", value: %{field1: "hi"}} + ] + end + + test "replace map patches are correctly processed by prepare_struct" do + source = %{"a" => "test"} + destination = %{"a" => %TestStruct{field1: "old"}} + + patches = Jsonpatch.diff(source, destination, prepare_struct: &%{field1: &1.field1}) + + assert patches == [ + %{op: "replace", path: "/a", value: %{field1: "old"}} + ] + end + defp assert_diff_apply(source, destination) do patches = Jsonpatch.diff(source, destination) assert Jsonpatch.apply_patch(patches, source) == {:ok, destination} From 6d95a3df65af1d525cb1b44567ed7a5d9ebcbeb1 Mon Sep 17 00:00:00 2001 From: Valian Date: Mon, 28 Jul 2025 18:52:39 +0200 Subject: [PATCH 7/9] renamed prepare_struct to prepare_map --- lib/jsonpatch.ex | 28 ++++++++++++++-------------- lib/jsonpatch/types.ex | 2 +- test/jsonpatch_test.exs | 41 +++++++++++++++++++++++++++++------------ 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/lib/jsonpatch.ex b/lib/jsonpatch.ex index d84824a..06ffb21 100644 --- a/lib/jsonpatch.ex +++ b/lib/jsonpatch.ex @@ -167,15 +167,15 @@ defmodule Jsonpatch do * `: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_struct` - A function that converts structs to maps before diffing. - Defaults to `fn struct -> struct end` (no-op). Useful when you need to customize - how structs are handled during the diff process. Example: + * `: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]) - struct -> struct + map -> map end ``` @@ -207,7 +207,7 @@ defmodule Jsonpatch do Keyword.validate!(opts, ancestor_path: "", # by default, a no-op - prepare_struct: fn struct -> struct end + prepare_map: fn map -> map end ) cond do @@ -237,7 +237,7 @@ defmodule Jsonpatch 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 - value = maybe_prepare_struct(dest, opts) + value = maybe_prepare_map(dest, opts) [%{op: "replace", path: "#{path}/#{escape(key)}", value: value} | patches] end @@ -247,9 +247,9 @@ defmodule Jsonpatch do end defp do_map_diff(%{} = destination, %{} = source, ancestor_path, patches, opts) do - # Convert structs to maps if prepare_struct function is provided - destination = maybe_prepare_struct(destination, opts) - source = maybe_prepare_struct(source, opts) + # 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 @@ -277,7 +277,7 @@ defmodule Jsonpatch do do_diff(val, source_val, ancestor_path, key, patches, opts) :error -> - value = maybe_prepare_struct(val, opts) + value = maybe_prepare_map(val, opts) [%{op: "add", path: "#{ancestor_path}/#{escape(key)}", value: value} | patches] end @@ -299,7 +299,7 @@ defmodule Jsonpatch 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: maybe_prepare_struct(val, opts)}, + {%{op: "add", path: "#{ancestor_path}/#{idx}", value: maybe_prepare_map(val, opts)}, idx + 1} end) |> elem(0) @@ -312,12 +312,12 @@ defmodule Jsonpatch do do_list_diff(rest, source_rest, ancestor_path, patches, idx + 1, opts) end - defp maybe_prepare_struct(value, opts) when is_struct(value) do - prepare_fn = Keyword.fetch!(opts, :prepare_struct) + 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_struct(value, _opts), do: value + defp maybe_prepare_map(value, _opts), do: value @compile {:inline, escape: 1} diff --git a/lib/jsonpatch/types.ex b/lib/jsonpatch/types.ex index 2ad9ee3..2ead42a 100644 --- a/lib/jsonpatch/types.ex +++ b/lib/jsonpatch/types.ex @@ -32,7 +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_struct, (struct() -> map())}] + @type opts_diff :: [{:ancestor_path, String.t()} | {:prepare_map, (struct() -> 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 b5e4e82..fa0cc94 100644 --- a/test/jsonpatch_test.exs +++ b/test/jsonpatch_test.exs @@ -187,7 +187,7 @@ defmodule JsonpatchTest do ] end - test "Create diff with prepare_struct option using subset of fields" do + test "Create diff with prepare_map option using subset of fields" do source = %TestStruct{ field1: "value1", field2: "value2", @@ -203,7 +203,12 @@ defmodule JsonpatchTest do } patches = - Jsonpatch.diff(source, destination, prepare_struct: &Map.take(&1, [:field1, :inner])) + 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"}, @@ -213,7 +218,7 @@ defmodule JsonpatchTest do assert_equal_patches(patches, expected_patches) end - test "Create diff with prepare_struct option using dynamic field creation" do + test "Create diff with prepare_map option using dynamic field creation" do source = %TestStruct{ field1: "hello", field2: "world" @@ -226,7 +231,7 @@ defmodule JsonpatchTest do patches = Jsonpatch.diff(source, destination, - prepare_struct: &%{field3: "#{&1.field1} - #{&1.field2}"} + prepare_map: &%{field3: "#{&1.field1} - #{&1.field2}"} ) expected_patches = [ @@ -236,7 +241,7 @@ defmodule JsonpatchTest do assert_equal_patches(patches, expected_patches) end - test "Create diff with prepare_struct option using nested dynamic field creation" do + test "Create diff with prepare_map option using nested dynamic field creation" do source = %TestStruct{ field1: "hello", field2: "world", @@ -251,7 +256,7 @@ defmodule JsonpatchTest do patches = Jsonpatch.diff(source, destination, - prepare_struct: &%{inner: &1.inner, field3: "#{&1.field1} - #{&1.field2}"} + prepare_map: &%{inner: &1.inner, field3: "#{&1.field1} - #{&1.field2}"} ) expected_patches = [ @@ -262,7 +267,7 @@ defmodule JsonpatchTest do assert_equal_patches(patches, expected_patches) end - test "add map patches are correctly processed by prepare_struct" do + test "add map patches are correctly processed by prepare_map" do source = %{} destination = %{ @@ -272,14 +277,20 @@ defmodule JsonpatchTest do } } - patches = Jsonpatch.diff(source, destination, prepare_struct: &%{field1: &1.field1}) + 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_struct" do + test "add list patches are correctly processed by prepare_map" do source = [] destination = [ @@ -289,18 +300,24 @@ defmodule JsonpatchTest do } ] - patches = Jsonpatch.diff(source, destination, prepare_struct: &%{field1: &1.field1}) + 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_struct" do + 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_struct: &%{field1: &1.field1}) + 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"}} From 2498375860b3663223d2857ce55f02476538a75b Mon Sep 17 00:00:00 2001 From: Valian Date: Mon, 28 Jul 2025 19:00:23 +0200 Subject: [PATCH 8/9] Add handling for type changes in diff function Implemented logic to create a replace operation when the type of the root value changes, such as setting a value to nil. Updated tests to verify this behavior, including cases with ancestor paths. --- lib/jsonpatch.ex | 5 +++++ test/jsonpatch_test.exs | 19 +++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/jsonpatch.ex b/lib/jsonpatch.ex index 06ffb21..d685123 100644 --- a/lib/jsonpatch.ex +++ b/lib/jsonpatch.ex @@ -217,6 +217,11 @@ defmodule Jsonpatch do 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 diff --git a/test/jsonpatch_test.exs b/test/jsonpatch_test.exs index fa0cc94..24bf95c 100644 --- a/test/jsonpatch_test.exs +++ b/test/jsonpatch_test.exs @@ -57,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 @@ -324,6 +324,21 @@ defmodule JsonpatchTest do ] 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} From 56ff17082e62bd2dcd10bcd6fbe1558ab843b009 Mon Sep 17 00:00:00 2001 From: Valian Date: Mon, 28 Jul 2025 20:08:57 +0200 Subject: [PATCH 9/9] Update opts_diff type to allow struct or map in prepare_map function --- lib/jsonpatch/types.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonpatch/types.ex b/lib/jsonpatch/types.ex index 2ead42a..229f06e 100644 --- a/lib/jsonpatch/types.ex +++ b/lib/jsonpatch/types.ex @@ -32,7 +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())}] + @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()