Skip to content

Commit 6ebfb99

Browse files
authored
Merge pull request #4 from corka149/bugfix/rework-diffing-maps-#3
Bugfix/rework diffing maps #3
2 parents 25478f6 + 9e8d761 commit 6ebfb99

8 files changed

Lines changed: 143 additions & 241 deletions

File tree

CHANGELOG

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# 0.11.0
2+
- Removed module Jsonpatch.FlatMap because it is not necessary anymore and not the focus of the lib
3+
- Reworked creating diff to create less unnecessary data and for more accurate patches
4+
- Fixed adding values to empty lists (thanks https://github.com/webdeb)
5+
16
# 0.10.0
27

38
- Made jsonpatch more Elixir-API-like by adding Jsonpatch.apply_patch! (which raise an exception) and changed Jsonpatch.apply_patch to return a tuple.
File renamed without changes.

lib/jsonpatch.ex

Lines changed: 92 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ defmodule Jsonpatch do
1010
by using `~1` for `/` and `~0` for `~`.
1111
"""
1212

13-
alias Jsonpatch.FlatMap
1413
alias Jsonpatch.Operation
1514
alias Jsonpatch.Operation.Add
1615
alias Jsonpatch.Operation.Copy
@@ -104,88 +103,122 @@ defmodule Jsonpatch do
104103
end
105104

106105
@doc """
107-
Creates a patch from the difference of a source map to a target map.
106+
Creates a patch from the difference of a source map to a destination map or list.
108107
109108
## Examples
110109
111110
iex> source = %{"name" => "Bob", "married" => false, "hobbies" => ["Elixir", "Sport", "Football"]}
112111
iex> destination = %{"name" => "Bob", "married" => true, "hobbies" => ["Elixir!"], "age" => 33}
113112
iex> Jsonpatch.diff(source, destination)
114113
[
115-
%Add{path: "/age", value: 33},
116-
%Replace{path: "/hobbies/0", value: "Elixir!"},
117-
%Replace{path: "/married", value: true},
118-
%Remove{path: "/hobbies/1"},
119-
%Remove{path: "/hobbies/2"}
114+
%Jsonpatch.Operation.Replace{path: "/married", value: true},
115+
%Jsonpatch.Operation.Remove{path: "/hobbies/2"},
116+
%Jsonpatch.Operation.Remove{path: "/hobbies/1"},
117+
%Jsonpatch.Operation.Replace{path: "/hobbies/0", value: "Elixir!"},
118+
%Jsonpatch.Operation.Add{path: "/age", value: 33}
120119
]
121120
"""
122-
@spec diff(map, map) :: list(Jsonpatch.t())
121+
@spec diff(maybe_improper_list | map, maybe_improper_list | map) :: list(Jsonpatch.t())
123122
def diff(source, destination)
124123

125124
def diff(%{} = source, %{} = destination) do
126-
source = FlatMap.parse(source)
127-
destination = FlatMap.parse(destination)
125+
Map.to_list(destination)
126+
|> do_diff(source, "")
127+
end
128+
129+
def diff(source, destination) when is_list(source) and is_list(destination) do
130+
Enum.with_index(destination)
131+
|> Enum.map(fn {v, k} -> {k, v} end)
132+
|> do_diff(source, "")
133+
end
128134

135+
def diff(_, _) do
129136
[]
130-
|> create_additions(source, destination)
131-
|> create_replaces(source, destination)
132-
|> create_removes(source, destination)
133137
end
134138

135-
@doc """
136-
Creates "add"-operations by using the keys of the destination and check their existence in the
137-
source map. Source and destination has to be parsed to a flat map.
138-
"""
139-
@spec create_additions(list(Jsonpatch.t()), map, map) :: list(Jsonpatch.t())
140-
def create_additions(accumulator \\ [], source, destination)
141-
142-
def create_additions(accumulator, %{} = source, %{} = destination) do
143-
additions =
144-
Map.keys(destination)
145-
|> Enum.filter(fn key -> not Map.has_key?(source, key) end)
146-
|> Enum.map(fn key ->
147-
%Add{path: key, value: Map.get(destination, key)}
148-
end)
149-
150-
accumulator ++ additions
139+
# ===== ===== PRIVATE ===== =====
140+
141+
# Helper for better readability
142+
defguardp are_unequal_maps(val1, val2)
143+
when val1 != val2 and is_map(val2) and is_map(val1)
144+
145+
# Helper for better readability
146+
defguardp are_unequal_lists(val1, val2)
147+
when val1 != val2 and is_list(val2) and is_list(val1)
148+
149+
# Diff reduce loop
150+
defp do_diff(destination, source, ancestor_path, acc \\ [], checked_keys \\ [])
151+
152+
defp do_diff([], source, ancestor_path, acc, checked_keys) do
153+
# The complete desination was check. Every key that is not in the list of
154+
# checked keys, must be removed.
155+
acc =
156+
source
157+
|> flat()
158+
|> Stream.map(fn {k, _} -> k end)
159+
|> Stream.filter(fn k -> k not in checked_keys end)
160+
|> Stream.map(fn k -> %Remove{path: "#{ancestor_path}/#{k}"} end)
161+
|> Enum.reduce(acc, fn r, acc -> [r | acc] end)
162+
163+
acc
151164
end
152165

153-
@doc """
154-
Creates "remove"-operations by using the keys of the destination and check their existence in the
155-
source map. Source and destination has to be parsed to a flat map.
156-
"""
157-
@spec create_removes(list(Jsonpatch.t()), map, map) :: list(Jsonpatch.t())
158-
def create_removes(accumulator \\ [], source, destination)
166+
defp do_diff([{key, val} | tail], source, ancestor_path, acc, checked_keys)
167+
when is_list(source) or is_map(source) do
168+
current_path = "#{ancestor_path}/#{escape(key)}"
169+
170+
acc =
171+
case get(source, key) do
172+
# Key is not present in source
173+
nil ->
174+
[%Add{path: current_path, value: val} | acc]
159175

160-
def create_removes(accumulator, %{} = source, %{} = destination) do
161-
removes =
162-
Map.keys(source)
163-
|> Enum.filter(fn key -> not Map.has_key?(destination, key) end)
164-
|> Enum.map(fn key -> %Remove{path: key} end)
176+
# Source has a different value but both (destination and source) value are lists or a maps
177+
source_val
178+
when are_unequal_lists(source_val, val) or are_unequal_maps(source_val, val) ->
179+
# Enter next level - set check_keys to empty list because it is a different level
180+
do_diff(flat(val), source_val, current_path, acc, [])
181+
182+
# Scalar source val that is not equal
183+
source_val when source_val != val ->
184+
[%Replace{path: current_path, value: val} | acc]
185+
186+
_ ->
187+
acc
188+
end
165189

166-
accumulator ++ removes
190+
# Diff next value of same level
191+
do_diff(tail, source, ancestor_path, acc, [escape(key) | checked_keys])
167192
end
168193

169-
@doc """
170-
Creates "replace"-operations by comparing keys and values of source and destination. The source and
171-
destination map have to be flat maps.
172-
"""
173-
@spec create_replaces(list(Jsonpatch.t()), map, map) :: list(Jsonpatch.t())
174-
def create_replaces(accumulator \\ [], source, destination)
175-
176-
def create_replaces(accumulator, source, destination) do
177-
replaces =
178-
Map.keys(destination)
179-
|> Enum.filter(fn key -> Map.has_key?(source, key) end)
180-
|> Enum.filter(fn key -> Map.get(source, key) != Map.get(destination, key) end)
181-
|> Enum.map(fn key ->
182-
%Replace{path: key, value: Map.get(destination, key)}
183-
end)
184-
185-
accumulator ++ replaces
194+
# Transforms a map into a tuple list and a list also into a tuple list with indizes
195+
defp flat(val) when is_list(val) do
196+
Stream.with_index(val) |> Enum.map(fn {v, k} -> {k, v} end)
186197
end
187198

188-
# ===== ===== PRIVATE ===== =====
199+
defp flat(val) when is_map(val) do
200+
Map.to_list(val)
201+
end
202+
203+
# Unified access to lists or maps
204+
defp get(source, key) when is_list(source) do
205+
Enum.at(source, key)
206+
end
207+
208+
defp get(source, key) do
209+
Map.get(source, key)
210+
end
211+
212+
# Escape `/` to `~1 and `~` to `~`.
213+
defp escape(subpath) when is_bitstring(subpath) do
214+
subpath
215+
|> String.replace("~", "~0")
216+
|> String.replace("/", "~1")
217+
end
218+
219+
defp escape(subpath) do
220+
subpath
221+
end
189222

190223
# Create once a easy sortable value for a operation
191224
defp create_sort_value(%{path: path} = operation) do

lib/jsonpatch/flat_map.ex

Lines changed: 0 additions & 69 deletions
This file was deleted.

lib/jsonpatch/operation/remove.ex

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ defimpl Jsonpatch.Operation, for: Jsonpatch.Operation.Remove do
7171

7272
case List.pop_at(target, index) do
7373
{nil, _} -> {:error, :invalid_index, fragment}
74-
{{:error, _, _} = error, _} -> error
7574
_ -> update_list
7675
end
7776
end

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ defmodule Jsonpatch.MixProject do
66
app: :jsonpatch,
77
name: "Jsonpatch",
88
description: "Implementation of RFC 6902 in pure Elixir",
9-
version: "0.10.0",
9+
version: "0.11.0",
1010
elixir: "~> 1.9",
1111
start_permanent: Mix.env() == :prod,
1212
deps: deps(),

test/jsonpatch/flat_map_test.exs

Lines changed: 0 additions & 67 deletions
This file was deleted.

0 commit comments

Comments
 (0)