Current section
Files
Jump to
Current section
Files
lib/graph/diff.ex
defmodule Paradigm.Graph.Diff do
alias Paradigm.Graph
@doc """
Computes the difference between two graphs, capturing missing nodes, added nodes, and changed attributes.
Returns a map with:
- `:added` - list of node_ids present in `new_graph` but not in `old_graph`
- `:removed` - list of node_ids present in `old_graph` but not in `new_graph`
- `:changed` - map of node_id => %{class: %{old: class, new: class}, data: %{key => %{old: value, new: value}}} for nodes with different attributes or class
"""
@spec diff(Graph.t(), Graph.t()) :: %{
added: [Graph.node_id()],
removed: [Graph.node_id()],
changed: %{Graph.node_id() => map()}
}
def diff(old_graph, new_graph) do
old_nodes_map =
Graph.stream_all_nodes(old_graph)
|> Enum.reduce(%{}, fn node, acc ->
Map.put(acc, node.id, node)
end)
new_nodes_map =
Graph.stream_all_nodes(new_graph)
|> Enum.reduce(%{}, fn node, acc ->
Map.put(acc, node.id, node)
end)
old_node_ids = MapSet.new(Map.keys(old_nodes_map))
new_node_ids = MapSet.new(Map.keys(new_nodes_map))
added = MapSet.difference(new_node_ids, old_node_ids) |> MapSet.to_list()
removed = MapSet.difference(old_node_ids, new_node_ids) |> MapSet.to_list()
common_nodes = MapSet.intersection(old_node_ids, new_node_ids)
changed =
common_nodes
|> Enum.reduce(%{}, fn node_id, acc ->
old_node = Map.get(old_nodes_map, node_id)
new_node = Map.get(new_nodes_map, node_id)
if old_node != new_node do
node_diff = compute_node_diff(old_node, new_node)
Map.put(acc, node_id, node_diff)
else
acc
end
end)
%{
added: added,
removed: removed,
changed: changed
}
end
defp compute_node_diff(old_node, new_node) do
diff = %{}
diff =
if old_node.class != new_node.class do
Map.put(diff, :class, %{old: old_node.class, new: new_node.class})
else
diff
end
diff =
if old_node.owned_by != new_node.owned_by do
Map.put(diff, :owned_by, %{old: old_node.owned_by, new: new_node.owned_by})
else
diff
end
old_data = old_node.data || %{}
new_data = new_node.data || %{}
all_keys = MapSet.union(MapSet.new(Map.keys(old_data)), MapSet.new(Map.keys(new_data)))
data_changes =
all_keys
|> Enum.reduce(%{}, fn key, acc ->
old_value = Map.fetch(old_data, key)
new_value = Map.fetch(new_data, key)
case {old_value, new_value} do
{{:ok, same}, {:ok, same}} -> acc
{{:ok, old}, {:ok, new}} -> Map.put(acc, key, %{old: old, new: new})
{{:ok, value}, :error} -> Map.put(acc, key, %{old: value, new: "MISSING VALUE"})
{:error, {:ok, value}} -> Map.put(acc, key, %{old: "MISSING VALUE", new: value})
end
end)
if data_changes != %{} do
Map.put(diff, :data, data_changes)
else
diff
end
end
@doc """
Asserts that two graphs are equal by computing their diff and throwing an error if there are any differences.
Raises an error if the graphs differ, otherwise returns :ok.
"""
@spec assert_equal(Graph.t(), Graph.t()) :: :ok
def assert_equal(old_graph, new_graph) do
differences = diff(old_graph, new_graph)
if differences.added == [] and differences.removed == [] and differences.changed == %{} do
:ok
else
error_message = format_differences(differences)
raise "Graphs are not equal.\n#{error_message}"
end
end
defp format_differences(differences) do
parts = []
parts =
if differences.added != [] do
added_section =
"Added nodes:\n" <>
Enum.map_join(differences.added, "\n", fn node_id -> " - #{node_id}" end)
[added_section | parts]
else
parts
end
parts =
if differences.removed != [] do
removed_section =
"Removed nodes:\n" <>
Enum.map_join(differences.removed, "\n", fn node_id -> " - #{node_id}" end)
[removed_section | parts]
else
parts
end
parts =
if differences.changed != %{} do
changed_section =
"Changed nodes:\n" <>
Enum.map_join(differences.changed, "\n", fn {node_id, changes} ->
node_changes = format_node_changes(changes)
" - #{node_id}:\n#{node_changes}"
end)
[changed_section | parts]
else
parts
end
Enum.reverse(parts) |> Enum.join("\n\n")
end
defp format_node_changes(changes) do
parts = []
parts =
if Map.has_key?(changes, :class) do
class_change = changes.class
class_section = " class: #{inspect(class_change.old)} → #{inspect(class_change.new)}"
[class_section | parts]
else
parts
end
parts =
if Map.has_key?(changes, :owned_by) do
owned_by_change = changes.owned_by
owned_by_section =
" owner: #{inspect(owned_by_change.old)} → #{inspect(owned_by_change.new)}"
[owned_by_section | parts]
else
parts
end
parts =
if Map.has_key?(changes, :data) do
data_changes = changes.data
data_section =
Enum.map_join(data_changes, "\n", fn {key, %{old: old_val, new: new_val}} ->
" #{key}: #{inspect(old_val)} → #{inspect(new_val)}"
end)
[data_section | parts]
else
parts
end
Enum.reverse(parts) |> Enum.join("\n")
end
end