Skip to content

Commit 0a67aad

Browse files
authored
Merge pull request #34 from Valian/prepare-struct-option
Prepare struct option
2 parents 2e1ed5b + 56ff170 commit 0a67aad

3 files changed

Lines changed: 231 additions & 39 deletions

File tree

lib/jsonpatch.ex

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,17 @@ defmodule Jsonpatch do
167167
168168
* `:ancestor_path` - Sets the initial ancestor path for the diff operation.
169169
Defaults to `""` (root). Useful when you need to diff starting from a nested path.
170+
* `:prepare_map` - A function that lets to customize maps and structs before diffing.
171+
Defaults to `fn map -> map end` (no-op). Useful when you need to customize
172+
how maps and structs are handled during the diff process. Example:
173+
174+
```elixir
175+
fn
176+
%Struct{field1: value1, field2: value2} -> %{field1: "\#{value1} - \#{value2}"}
177+
%OtherStruct{} = struct -> Map.take(struct, [:field1])
178+
map -> map
179+
end
180+
```
170181
171182
## Examples
172183
@@ -192,14 +203,24 @@ defmodule Jsonpatch do
192203
"""
193204
@spec diff(Types.json_container(), Types.json_container(), Types.opts_diff()) :: [Jsonpatch.t()]
194205
def diff(source, destination, opts \\ []) do
195-
opts = Keyword.validate!(opts, ancestor_path: "")
206+
opts =
207+
Keyword.validate!(opts,
208+
ancestor_path: "",
209+
# by default, a no-op
210+
prepare_map: fn map -> map end
211+
)
196212

197213
cond do
198214
is_map(source) and is_map(destination) ->
199-
do_map_diff(destination, source, opts[:ancestor_path])
215+
do_map_diff(destination, source, opts[:ancestor_path], [], opts)
200216

201217
is_list(source) and is_list(destination) ->
202-
do_list_diff(destination, source, opts[:ancestor_path])
218+
do_list_diff(destination, source, opts[:ancestor_path], [], 0, opts)
219+
220+
# type of value changed, eg set to nil
221+
source != destination ->
222+
destination = maybe_prepare_map(destination, opts)
223+
[%{op: "replace", path: opts[:ancestor_path], value: destination}]
203224

204225
true ->
205226
[]
@@ -209,34 +230,39 @@ defmodule Jsonpatch do
209230
defguardp are_unequal_maps(val1, val2) when val1 != val2 and is_map(val2) and is_map(val1)
210231
defguardp are_unequal_lists(val1, val2) when val1 != val2 and is_list(val2) and is_list(val1)
211232

212-
defp do_diff(dest, source, path, key, patches) when are_unequal_lists(dest, source) do
233+
defp do_diff(dest, source, path, key, patches, opts) when are_unequal_lists(dest, source) do
213234
# uneqal lists, let's use a specialized function for that
214-
do_list_diff(dest, source, "#{path}/#{escape(key)}", patches)
235+
do_list_diff(dest, source, "#{path}/#{escape(key)}", patches, 0, opts)
215236
end
216237

217-
defp do_diff(dest, source, path, key, patches) when are_unequal_maps(dest, source) do
238+
defp do_diff(dest, source, path, key, patches, opts) when are_unequal_maps(dest, source) do
218239
# uneqal maps, let's use a specialized function for that
219-
do_map_diff(dest, source, "#{path}/#{escape(key)}", patches)
240+
do_map_diff(dest, source, "#{path}/#{escape(key)}", patches, opts)
220241
end
221242

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

227-
defp do_diff(_dest, _source, _path, _key, patches) do
249+
defp do_diff(_dest, _source, _path, _key, patches, _opts) do
228250
# no changes, return patches as is
229251
patches
230252
end
231253

232-
defp do_map_diff(%{} = destination, %{} = source, ancestor_path, patches \\ []) do
254+
defp do_map_diff(%{} = destination, %{} = source, ancestor_path, patches, opts) do
255+
# Convert structs to maps if prepare_map function is provided
256+
destination = maybe_prepare_map(destination, opts)
257+
source = maybe_prepare_map(source, opts)
258+
233259
# entrypoint for map diff, let's convert the map to a list of {k, v} tuples
234260
destination
235261
|> Map.to_list()
236-
|> do_map_diff(source, ancestor_path, patches, [])
262+
|> do_map_diff(source, ancestor_path, patches, [], opts)
237263
end
238264

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

251-
defp do_map_diff([{key, val} | rest], source, ancestor_path, patches, checked_keys) do
277+
defp do_map_diff([{key, val} | rest], source, ancestor_path, patches, checked_keys, opts) do
252278
# normal iteration through list of map {k, v} tuples. We track seen keys to later remove not seen keys.
253279
patches =
254280
case Map.fetch(source, key) do
255-
{:ok, source_val} -> do_diff(val, source_val, ancestor_path, key, patches)
256-
:error -> [%{op: "add", path: "#{ancestor_path}/#{escape(key)}", value: val} | patches]
281+
{:ok, source_val} ->
282+
do_diff(val, source_val, ancestor_path, key, patches, opts)
283+
284+
:error ->
285+
value = maybe_prepare_map(val, opts)
286+
[%{op: "add", path: "#{ancestor_path}/#{escape(key)}", value: value} | patches]
257287
end
258288

259289
# Diff next value of same level
260-
do_map_diff(rest, source, ancestor_path, patches, [key | checked_keys])
290+
do_map_diff(rest, source, ancestor_path, patches, [key | checked_keys], opts)
261291
end
262292

263-
defp do_list_diff(destination, source, ancestor_path, patches \\ [], idx \\ 0)
293+
defp do_list_diff(destination, source, ancestor_path, patches, idx, opts)
264294

265-
defp do_list_diff([], [], _path, patches, _idx), do: patches
295+
defp do_list_diff([], [], _path, patches, _idx, _opts), do: patches
266296

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

273-
defp do_list_diff(items, [], ancestor_path, patches, idx) do
303+
defp do_list_diff(items, [], ancestor_path, patches, idx, opts) do
274304
# we have to do it without recursion, because we have to keep the order of the items
275305
items
276306
|> Enum.map_reduce(idx, fn val, idx ->
277-
{%{op: "add", path: "#{ancestor_path}/#{idx}", value: val}, idx + 1}
307+
{%{op: "add", path: "#{ancestor_path}/#{idx}", value: maybe_prepare_map(val, opts)},
308+
idx + 1}
278309
end)
279310
|> elem(0)
280311
|> Kernel.++(patches)
281312
end
282313

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

320+
defp maybe_prepare_map(value, opts) when is_map(value) do
321+
prepare_fn = Keyword.fetch!(opts, :prepare_map)
322+
prepare_fn.(value)
323+
end
324+
325+
defp maybe_prepare_map(value, _opts), do: value
326+
289327
@compile {:inline, escape: 1}
290328

291329
defp escape(fragment) when is_binary(fragment) do

lib/jsonpatch/types.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ defmodule Jsonpatch.Types do
3232
- `:keys` - controls how path fragments are decoded.
3333
"""
3434
@type opts :: [{:keys, opt_keys()}]
35-
@type opts_diff :: [{:ancestor_path, String.t()}]
35+
@type opts_diff :: [{:ancestor_path, String.t()} | {:prepare_map, (struct() | map() -> map())}]
3636

3737
@type casted_array_index :: :- | non_neg_integer()
3838
@type casted_object_key :: atom() | String.t()

test/jsonpatch_test.exs

Lines changed: 166 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ defmodule JsonpatchTest do
33

44
doctest Jsonpatch
55

6+
defmodule TestStruct do
7+
defstruct [:field1, :field2, :inner, :field]
8+
end
9+
610
test "Create diff from list and apply it" do
711
# Arrange
812
source = [1, 2, %{"drei" => 3}, 5, 6]
@@ -53,8 +57,8 @@ defmodule JsonpatchTest do
5357
assert [] = Jsonpatch.diff(source, destination)
5458
end
5559

56-
test "Create no diff on unexpected input" do
57-
assert [] = Jsonpatch.diff("unexpected", 1)
60+
test "Create full replace operation when type of root value changes" do
61+
assert [%{op: "replace", path: "", value: 1}] = Jsonpatch.diff("unexpected", 1)
5862
end
5963

6064
test "A.4. Removing an Array Element" do
@@ -178,8 +182,158 @@ defmodule JsonpatchTest do
178182

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

185+
assert patches == [
186+
%{op: "replace", path: "/escape~1me~0now/a", value: 2}
187+
]
188+
end
189+
190+
test "Create diff with prepare_map option using subset of fields" do
191+
source = %TestStruct{
192+
field1: "value1",
193+
field2: "value2",
194+
inner: %{nested: "old"},
195+
field: "ignored"
196+
}
197+
198+
destination = %TestStruct{
199+
field1: "new_value",
200+
field2: "value2",
201+
inner: %{nested: "new"},
202+
field: "also_ignored"
203+
}
204+
205+
patches =
206+
Jsonpatch.diff(source, destination,
207+
prepare_map: fn
208+
%TestStruct{field1: field1, inner: inner} -> %{field1: field1, inner: inner}
209+
map -> map
210+
end
211+
)
212+
213+
expected_patches = [
214+
%{op: "replace", path: "/field1", value: "new_value"},
215+
%{op: "replace", path: "/inner/nested", value: "new"}
216+
]
217+
218+
assert_equal_patches(patches, expected_patches)
219+
end
220+
221+
test "Create diff with prepare_map option using dynamic field creation" do
222+
source = %TestStruct{
223+
field1: "hello",
224+
field2: "world"
225+
}
226+
227+
destination = %TestStruct{
228+
field1: "hi",
229+
field2: "world"
230+
}
231+
232+
patches =
233+
Jsonpatch.diff(source, destination,
234+
prepare_map: &%{field3: "#{&1.field1} - #{&1.field2}"}
235+
)
236+
237+
expected_patches = [
238+
%{op: "replace", path: "/field3", value: "hi - world"}
239+
]
240+
241+
assert_equal_patches(patches, expected_patches)
242+
end
243+
244+
test "Create diff with prepare_map option using nested dynamic field creation" do
245+
source = %TestStruct{
246+
field1: "hello",
247+
field2: "world",
248+
inner: %TestStruct{field1: "nested", field2: "old"}
249+
}
250+
251+
destination = %TestStruct{
252+
field1: "hi",
253+
field2: "world",
254+
inner: %TestStruct{field1: "nested", field2: "new"}
255+
}
256+
257+
patches =
258+
Jsonpatch.diff(source, destination,
259+
prepare_map: &%{inner: &1.inner, field3: "#{&1.field1} - #{&1.field2}"}
260+
)
261+
262+
expected_patches = [
263+
%{op: "replace", path: "/field3", value: "hi - world"},
264+
%{op: "replace", path: "/inner/field3", value: "nested - new"}
265+
]
266+
267+
assert_equal_patches(patches, expected_patches)
268+
end
269+
270+
test "add map patches are correctly processed by prepare_map" do
271+
source = %{}
272+
273+
destination = %{
274+
a: %TestStruct{
275+
field1: "hi",
276+
field2: "world"
277+
}
278+
}
279+
280+
patches =
281+
Jsonpatch.diff(source, destination,
282+
prepare_map: fn
283+
%TestStruct{field1: field1} -> %{field1: field1}
284+
map -> map
285+
end
286+
)
287+
288+
assert patches == [
289+
%{op: "add", path: "/a", value: %{field1: "hi"}}
290+
]
291+
end
292+
293+
test "add list patches are correctly processed by prepare_map" do
294+
source = []
295+
296+
destination = [
297+
%TestStruct{
298+
field1: "hi",
299+
field2: "world"
300+
}
301+
]
302+
303+
patches = Jsonpatch.diff(source, destination, prepare_map: &%{field1: &1.field1})
304+
305+
assert patches == [
306+
%{op: "add", path: "/0", value: %{field1: "hi"}}
307+
]
308+
end
309+
310+
test "replace map patches are correctly processed by prepare_map" do
311+
source = %{"a" => "test"}
312+
destination = %{"a" => %TestStruct{field1: "old"}}
313+
314+
patches =
315+
Jsonpatch.diff(source, destination,
316+
prepare_map: fn
317+
%TestStruct{field1: field1} -> %{field1: field1}
318+
map -> map
319+
end
320+
)
321+
322+
assert patches == [
323+
%{op: "replace", path: "/a", value: %{field1: "old"}}
324+
]
325+
end
326+
327+
test "Create diff with ancestor_path when changing type of base value (map to nil)" do
328+
source = %{"key" => "value"}
329+
destination = nil
330+
331+
patches = Jsonpatch.diff(source, destination, ancestor_path: "/nested")
332+
333+
# This should fail for now - the diff should not handle type changes with ancestor_path
334+
# The expected behavior would be to generate a replace operation for the entire data object
181335
expected_patches = [
182-
%{op: "replace", path: "/escape~1me~0now/a", value: 2}
336+
%{op: "replace", path: "/nested", value: nil}
183337
]
184338

185339
assert patches == expected_patches
@@ -330,18 +484,14 @@ defmodule JsonpatchTest do
330484
}} = Jsonpatch.apply_patch(patch, target, keys: {:custom, convert_fn})
331485
end
332486

333-
defmodule TestStruct do
334-
defstruct [:field]
335-
end
336-
337487
test "struct are just maps" do
338-
patch = %Jsonpatch.Operation.Replace{path: "/a/field/c", value: 1}
339-
target = %{a: %TestStruct{field: %{c: 0}}}
488+
patch = %Jsonpatch.Operation.Replace{path: "/a/field1/c", value: 1}
489+
target = %{a: %TestStruct{field1: %{c: 0}}}
340490
patched = Jsonpatch.apply_patch!(patch, target, keys: :atoms)
341-
assert %{a: %TestStruct{field: %{c: 1}}} = patched
491+
assert %{a: %TestStruct{field1: %{c: 1}}} = patched
342492

343-
patch = %Jsonpatch.Operation.Remove{path: "/a/field"}
344-
target = %{a: %TestStruct{field: %{c: 0}}}
493+
patch = %Jsonpatch.Operation.Remove{path: "/a/field1"}
494+
target = %{a: %TestStruct{field1: %{c: 0}}}
345495
patched = Jsonpatch.apply_patch!(patch, target, keys: :atoms)
346496
assert %{a: %{__struct__: TestStruct}} = patched
347497
end
@@ -492,6 +642,10 @@ defmodule JsonpatchTest do
492642
end
493643
end
494644

645+
defp assert_equal_patches(patches1, patches2) do
646+
assert Enum.sort_by(patches1, & &1.path) == Enum.sort_by(patches2, & &1.path)
647+
end
648+
495649
defp string_to_existing_atom(data) when is_binary(data) do
496650
{:ok, String.to_existing_atom(data)}
497651
rescue

0 commit comments

Comments
 (0)