Skip to content

Commit 46fc20d

Browse files
authored
Merge pull request #28 from Valian/diff-performance-optimization
Performance optimizations - Dedicated functions for map and list diffs
2 parents 94cb7cd + d04a22c commit 46fc20d

File tree

4 files changed

+337
-46
lines changed

4 files changed

+337
-46
lines changed

lib/jsonpatch.ex

Lines changed: 78 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ defmodule Jsonpatch do
1212

1313
alias Jsonpatch.Types
1414
alias Jsonpatch.Operation.{Add, Copy, Move, Remove, Replace, Test}
15-
alias Jsonpatch.Utils
1615

1716
@typedoc """
1817
A valid Jsonpatch operation by RFC 6902
@@ -181,75 +180,109 @@ defmodule Jsonpatch do
181180
def diff(source, destination)
182181

183182
def diff(%{} = source, %{} = destination) do
184-
flat(destination)
185-
|> do_diff(source, "")
183+
do_map_diff(destination, source)
186184
end
187185

188186
def diff(source, destination) when is_list(source) and is_list(destination) do
189-
flat(destination)
190-
|> do_diff(source, "")
187+
do_list_diff(destination, source)
191188
end
192189

193190
def diff(_, _) do
194191
[]
195192
end
196193

197-
defguardp are_unequal_maps(val1, val2)
198-
when val1 != val2 and is_map(val2) and is_map(val1)
194+
defguardp are_unequal_maps(val1, val2) when val1 != val2 and is_map(val2) and is_map(val1)
195+
defguardp are_unequal_lists(val1, val2) when val1 != val2 and is_list(val2) and is_list(val1)
199196

200-
defguardp are_unequal_lists(val1, val2)
201-
when val1 != val2 and is_list(val2) and is_list(val1)
197+
defp do_diff(dest, source, path, key, patches) when are_unequal_lists(dest, source) do
198+
# uneqal lists, let's use a specialized function for that
199+
do_list_diff(dest, source, "#{path}/#{escape(key)}", patches)
200+
end
201+
202+
defp do_diff(dest, source, path, key, patches) when are_unequal_maps(dest, source) do
203+
# uneqal maps, let's use a specialized function for that
204+
do_map_diff(dest, source, "#{path}/#{escape(key)}", patches)
205+
end
202206

203-
# Diff reduce loop
204-
defp do_diff(destination, source, ancestor_path, acc \\ [], checked_keys \\ [])
207+
defp do_diff(dest, source, path, key, patches) when dest != source do
208+
# scalar values or change of type (map -> list etc), let's just make a replace patch
209+
[%{op: "replace", path: "#{path}/#{escape(key)}", value: dest} | patches]
210+
end
211+
212+
defp do_diff(_dest, _source, _path, _key, patches) do
213+
# no changes, return patches as is
214+
patches
215+
end
205216

206-
defp do_diff([], source, ancestor_path, patches, checked_keys) do
217+
defp do_map_diff(%{} = destination, %{} = source, ancestor_path \\ "", patches \\ []) do
218+
# entrypoint for map diff, let's convert the map to a list of {k, v} tuples
219+
destination
220+
|> Map.to_list()
221+
|> do_map_diff(source, ancestor_path, patches, [])
222+
end
223+
224+
defp do_map_diff([], source, ancestor_path, patches, checked_keys) do
207225
# The complete desination was check. Every key that is not in the list of
208226
# checked keys, must be removed.
209-
source
210-
|> flat()
211-
|> Stream.map(fn {k, _} -> escape(k) end)
212-
|> Stream.filter(fn k -> k not in checked_keys end)
213-
|> Stream.map(fn k -> %{op: "remove", path: "#{ancestor_path}/#{k}"} end)
214-
|> Enum.reduce(patches, fn remove_patch, patches -> [remove_patch | patches] end)
227+
Enum.reduce(source, patches, fn {k, _}, patches ->
228+
if k in checked_keys do
229+
patches
230+
else
231+
[%{op: "remove", path: "#{ancestor_path}/#{escape(k)}"} | patches]
232+
end
233+
end)
215234
end
216235

217-
defp do_diff([{key, val} | tail], source, ancestor_path, patches, checked_keys) do
218-
current_path = "#{ancestor_path}/#{escape(key)}"
219-
236+
defp do_map_diff([{key, val} | rest], source, ancestor_path, patches, checked_keys) do
237+
# normal iteration through list of map {k, v} tuples. We track seen keys to later remove not seen keys.
220238
patches =
221-
case Utils.fetch(source, key) do
222-
# Key is not present in source
223-
{:error, _} ->
224-
[%{op: "add", path: current_path, value: val} | patches]
239+
case Map.fetch(source, key) do
240+
{:ok, source_val} -> do_diff(val, source_val, ancestor_path, key, patches)
241+
:error -> [%{op: "add", path: "#{ancestor_path}/#{escape(key)}", value: val} | patches]
242+
end
225243

226-
# Source has a different value but both (destination and source) value are lists or a maps
227-
{:ok, source_val} when are_unequal_lists(source_val, val) ->
228-
val |> flat() |> Enum.reverse() |> do_diff(source_val, current_path, patches, [])
244+
# Diff next value of same level
245+
do_map_diff(rest, source, ancestor_path, patches, [key | checked_keys])
246+
end
229247

230-
{:ok, source_val} when are_unequal_maps(source_val, val) ->
231-
# Enter next level - set check_keys to empty list because it is a different level
232-
val |> flat() |> do_diff(source_val, current_path, patches, [])
248+
defp do_list_diff(destination, source, ancestor_path \\ "", patches \\ [], idx \\ 0)
233249

234-
# Scalar source val that is not equal
235-
{:ok, source_val} when source_val != val ->
236-
[%{op: "replace", path: current_path, value: val} | patches]
250+
defp do_list_diff([], [], _path, patches, _idx), do: patches
237251

238-
_ ->
239-
patches
240-
end
252+
defp do_list_diff([], [_item | source_rest], ancestor_path, patches, idx) do
253+
# if we find any leftover items in source, we have to remove them
254+
patches = [%{op: "remove", path: "#{ancestor_path}/#{idx}"} | patches]
255+
do_list_diff([], source_rest, ancestor_path, patches, idx + 1)
256+
end
241257

242-
# Diff next value of same level
243-
do_diff(tail, source, ancestor_path, patches, [escape(key) | checked_keys])
258+
defp do_list_diff(items, [], ancestor_path, patches, idx) do
259+
# we have to do it without recursion, because we have to keep the order of the items
260+
items
261+
|> Enum.map_reduce(idx, fn val, idx ->
262+
{%{op: "add", path: "#{ancestor_path}/#{idx}", value: val}, idx + 1}
263+
end)
264+
|> elem(0)
265+
|> Kernel.++(patches)
266+
end
267+
268+
defp do_list_diff([val | rest], [source_val | source_rest], ancestor_path, patches, idx) do
269+
# case when there's an item in both desitation and source. Let's just compare them
270+
patches = do_diff(val, source_val, ancestor_path, idx, patches)
271+
do_list_diff(rest, source_rest, ancestor_path, patches, idx + 1)
244272
end
245273

246-
# Transforms a map into a tuple list and a list also into a tuple list with indizes
247-
defp flat(val) when is_list(val),
248-
do: Stream.with_index(val) |> Enum.map(fn {v, k} -> {k, v} end)
274+
@compile {:inline, escape: 1}
249275

250-
defp flat(val) when is_map(val),
251-
do: Map.to_list(val)
276+
defp escape(fragment) when is_binary(fragment) do
277+
fragment =
278+
if :binary.match(fragment, "~") != :nomatch,
279+
do: String.replace(fragment, "~", "~0"),
280+
else: fragment
281+
282+
if :binary.match(fragment, "/") != :nomatch,
283+
do: String.replace(fragment, "/", "~1"),
284+
else: fragment
285+
end
252286

253-
defp escape(fragment) when is_binary(fragment), do: Utils.escape(fragment)
254287
defp escape(fragment), do: fragment
255288
end

mix.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ defmodule Jsonpatch.MixProject do
4040
{:credo, "~> 1.7.5", only: [:dev, :test], runtime: false},
4141
{:dialyxir, "~> 1.4", only: [:dev], runtime: false},
4242
{:ex_doc, "~> 0.31", only: [:dev], runtime: false},
43-
{:jason, "~> 1.4", only: [:dev, :test]}
43+
{:jason, "~> 1.4", only: [:dev, :test]},
44+
{:benchee, "~> 1.4", only: [:dev]}
4445
]
4546
end
4647

mix.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
%{
2+
"benchee": {:hex, :benchee, "1.4.0", "9f1f96a30ac80bab94faad644b39a9031d5632e517416a8ab0a6b0ac4df124ce", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "299cd10dd8ce51c9ea3ddb74bb150f93d25e968f93e4c1fa31698a8e4fa5d715"},
23
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
34
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
45
"credo": {:hex, :credo, "1.7.5", "643213503b1c766ec0496d828c90c424471ea54da77c8a168c725686377b9545", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f799e9b5cd1891577d8c773d245668aa74a2fcd15eb277f51a0131690ebfb3fd"},
6+
"deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"},
57
"dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"},
68
"earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"},
79
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
@@ -19,5 +21,6 @@
1921
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
2022
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
2123
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
24+
"statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"},
2225
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
2326
}

0 commit comments

Comments
 (0)