Current section
Files
Jump to
Current section
Files
lib/json_comparator.ex
defmodule JsonComparator do
@moduledoc """
Provides functionality for comparing JSON structures with configurable comparison options.
"""
@doc """
Compares two JSON structures for equality with configurable options.
This function performs a deep comparison of two JSON structures, supporting various data types
including maps, lists, DateTime objects, and structs. It provides configurable behavior for
list comparison and DateTime precision.
## Parameters
* `json1` - First JSON structure to compare
* `json2` - Second JSON structure to compare
* `opts` - Optional keyword list of comparison options:
* `:strict_list_order` - When `true`, lists must have identical order to be considered equal.
Defaults to `false`
* `:truncate_datetime_microseconds` - When `true`, DateTime comparisons ignore microseconds.
Defaults to `true`
* `:error_message` - Custom error message template to use when differences are found.
The string `%{path}` will be replaced with the path where the difference was found.
Defaults to "Submitted JSONs do not match: %{path}"
## Returns
* `:ok` - When the structures are equal according to the comparison rules
* `{:error, message}` - When differences are found, where message is a string indicating
the path where the first difference was encountered
"""
def compare(json1, json2, opts \\ []) do
opts =
[
strict_list_order: Keyword.get(opts, :strict_list_order, false),
truncate_datetime_microseconds: Keyword.get(opts, :truncate_datetime_microseconds, true),
error_message: Keyword.get(opts, :error_message, "Submitted JSONs do not match: %{path}")
]
case deep_compare(json1, json2, opts) do
{:ok, true} ->
:ok
{:ok, false} ->
{:error, String.replace(opts[:error_message], "%{path}", "")}
{:ok, {false, path}} ->
path_str = to_string(path)
{:error, String.replace(opts[:error_message], "%{path}", path_str)}
end
end
defp deep_compare(%DateTime{} = dt1, %DateTime{} = dt2, opts) do
compare_result =
if Keyword.get(opts, :truncate_datetime_microseconds, true) do
dt1_truncated = %{dt1 | microsecond: {0, 0}}
dt2_truncated = %{dt2 | microsecond: {0, 0}}
DateTime.compare(dt1_truncated, dt2_truncated) == :eq
else
DateTime.compare(dt1, dt2) == :eq
end
{:ok, compare_result}
end
defp deep_compare(%_{} = struct1, %_{} = struct2, _opts) do
{:ok, struct1.__struct__ == struct2.__struct__ and struct1 == struct2}
end
defp deep_compare(map1, map2, opts) when is_map(map1) and is_map(map2) do
compare_map_keys(Map.keys(map1), Map.keys(map2), map1, map2, opts)
end
defp deep_compare(list1, list2, opts) when is_list(list1) and is_list(list2) do
compare_lists(list1, list2, opts)
end
defp deep_compare(val1, val2, _opts) do
{:ok, val1 === val2}
end
defp compare_map_keys(keys1, keys2, map1, map2, opts) do
case keys1 -- keys2 do
[] ->
case keys2 -- keys1 do
[] -> compare_map_values(keys1, map1, map2, opts)
[missing_key | _] -> {:ok, {false, missing_key}}
end
[missing_key | _] ->
{:ok, {false, missing_key}}
end
end
defp compare_map_values(keys, map1, map2, opts) do
Enum.reduce_while(keys, {:ok, true}, fn key, acc ->
case deep_compare(map1[key], map2[key], opts) do
{:ok, true} -> {:cont, acc}
{:ok, {false, path}} -> {:halt, {:ok, {false, "#{key}.#{path}"}}}
{:ok, false} -> {:halt, {:ok, {false, key}}}
end
end)
end
defp compare_lists(list1, list2, opts) do
cond do
length(list1) != length(list2) ->
{:ok, {false, "[#{min(length(list1), length(list2))}]"}}
Keyword.get(opts, :strict_list_order, false) ->
compare_lists_strict(list1, list2, opts)
true ->
compare_lists_unordered(list1, list2, opts)
end
end
defp compare_lists_strict(list1, list2, opts) do
list1
|> Enum.zip(list2)
|> Enum.with_index()
|> Enum.reduce_while({:ok, true}, fn {{item1, item2}, index}, acc ->
case deep_compare(item1, item2, opts) do
{:ok, true} -> {:cont, acc}
{:ok, {false, path}} -> {:halt, {:ok, {false, "[#{index}]#{path}"}}}
{:ok, false} -> {:halt, {:ok, {false, "[#{index}]"}}}
end
end)
end
defp compare_lists_unordered(list1, list2, opts) do
result =
Enum.reduce_while(list1, list2, fn item1, acc ->
case Enum.find_index(acc, &match?({:ok, true}, deep_compare(&1, item1, opts))) do
nil -> {:halt, nil}
idx -> {:cont, List.delete_at(acc, idx)}
end
end)
{:ok, result == []}
end
end