Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 78 additions & 45 deletions lib/jsonpatch.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ defmodule Jsonpatch do

alias Jsonpatch.Types
alias Jsonpatch.Operation.{Add, Copy, Move, Remove, Replace, Test}
alias Jsonpatch.Utils

@typedoc """
A valid Jsonpatch operation by RFC 6902
Expand Down Expand Up @@ -181,75 +180,109 @@ defmodule Jsonpatch do
def diff(source, destination)

def diff(%{} = source, %{} = destination) do
flat(destination)
|> do_diff(source, "")
do_map_diff(destination, source)
end

def diff(source, destination) when is_list(source) and is_list(destination) do
flat(destination)
|> do_diff(source, "")
do_list_diff(destination, source)
end

def diff(_, _) do
[]
end

defguardp are_unequal_maps(val1, val2)
when val1 != val2 and is_map(val2) and is_map(val1)
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)

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
# uneqal lists, let's use a specialized function for that
do_list_diff(dest, source, "#{path}/#{escape(key)}", patches)
end

defp do_diff(dest, source, path, key, patches) 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)
end

# Diff reduce loop
defp do_diff(destination, source, ancestor_path, acc \\ [], checked_keys \\ [])
defp do_diff(dest, source, path, key, patches) 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
# no changes, return patches as is
patches
end

defp do_diff([], source, ancestor_path, patches, checked_keys) 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()
|> do_map_diff(source, ancestor_path, patches, [])
end

defp do_map_diff([], source, ancestor_path, patches, checked_keys) do
# The complete desination was check. Every key that is not in the list of
# checked keys, must be removed.
source
|> flat()
|> Stream.map(fn {k, _} -> escape(k) end)
|> Stream.filter(fn k -> k not in checked_keys end)
|> Stream.map(fn k -> %{op: "remove", path: "#{ancestor_path}/#{k}"} end)
|> Enum.reduce(patches, fn remove_patch, patches -> [remove_patch | patches] end)
Enum.reduce(source, patches, fn {k, _}, patches ->
if k in checked_keys do
patches
else
[%{op: "remove", path: "#{ancestor_path}/#{escape(k)}"} | patches]
end
end)
end

defp do_diff([{key, val} | tail], source, ancestor_path, patches, checked_keys) do
current_path = "#{ancestor_path}/#{escape(key)}"

defp do_map_diff([{key, val} | rest], source, ancestor_path, patches, checked_keys) do
# normal iteration through list of map {k, v} tuples. We track seen keys to later remove not seen keys.
patches =
case Utils.fetch(source, key) do
# Key is not present in source
{:error, _} ->
[%{op: "add", path: current_path, value: val} | 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]
end

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

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

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

_ ->
patches
end
defp do_list_diff([], [_item | source_rest], ancestor_path, patches, idx) 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)
end

# Diff next value of same level
do_diff(tail, source, ancestor_path, patches, [escape(key) | checked_keys])
defp do_list_diff(items, [], ancestor_path, patches, idx) 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}
end)
|> elem(0)
|> Kernel.++(patches)
end

defp do_list_diff([val | rest], [source_val | source_rest], ancestor_path, patches, idx) 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)
end

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

defp flat(val) when is_map(val),
do: Map.to_list(val)
defp escape(fragment) when is_binary(fragment) do
fragment =
if :binary.match(fragment, "~") != :nomatch,
do: String.replace(fragment, "~", "~0"),
else: fragment

if :binary.match(fragment, "/") != :nomatch,
do: String.replace(fragment, "/", "~1"),
else: fragment
end

defp escape(fragment) when is_binary(fragment), do: Utils.escape(fragment)
defp escape(fragment), do: fragment
end
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ defmodule Jsonpatch.MixProject do
{:credo, "~> 1.7.5", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: [:dev], runtime: false},
{:ex_doc, "~> 0.31", only: [:dev], runtime: false},
{:jason, "~> 1.4", only: [:dev, :test]}
{:jason, "~> 1.4", only: [:dev, :test]},
{:benchee, "~> 1.4", only: [:dev]}
]
end

Expand Down
3 changes: 3 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
%{
"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"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
"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"},
"deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"},
"dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"},
"earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
Expand All @@ -19,5 +21,6 @@
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
}
Loading
Loading