Packages

Build Elixir structs with validation, sanitization, nested sub-structs, conditional fields, pattern-keyed maps, and an Ash extension. Built on Spark.

Current section

Files

Jump to
guarded_struct lib guarded_struct diff.ex
Raw

lib/guarded_struct/diff.ex

defmodule GuardedStruct.Diff do
@moduledoc """
Field-level diffs between two GuardedStruct instances.
iex> GuardedStruct.Diff.diff(
...> %User{name: "Alice", age: 30, role: "admin"},
...> %User{name: "Alice", age: 31, role: "user"}
...> )
%{
age: {:changed, 30, 31},
role: {:changed, "admin", "user"}
}
Equal fields are omitted. Nested struct fields recurse — `name: %User{} → %User{}`
produces a nested map, not a `:changed` tuple.
Useful for audit logs, CRM history, and "what changed" UIs.
"""
@doc """
Diff two structs of the same module. Returns a map keyed by field name with
values of one of:
* `{:changed, old, new}` — primitive value differs
* `%{...}` — nested struct, recursively diffed (only if there ARE changes)
Equal fields are omitted from the result.
Returns `:not_comparable` if the two values are not the same struct module.
"""
@spec diff(struct() | map() | any(), struct() | map() | any()) :: map() | :not_comparable
def diff(%mod{} = a, %mod{} = b), do: diff_keys(Map.from_struct(a), Map.from_struct(b))
def diff(%{} = a, %{} = b) when not is_struct(a) and not is_struct(b), do: diff_keys(a, b)
def diff(_, _), do: :not_comparable
defp diff_keys(a, b) do
keys = Map.keys(a) |> Enum.uniq()
Enum.reduce(keys, %{}, fn key, acc ->
av = Map.get(a, key)
bv = Map.get(b, key)
case compare(av, bv) do
:unchanged -> acc
change -> Map.put(acc, key, change)
end
end)
end
defp compare(v, v), do: :unchanged
defp compare(%mod{} = a, %mod{} = b) do
case diff(a, b) do
d when d == %{} -> :unchanged
d -> d
end
end
defp compare(a, b), do: {:changed, a, b}
@doc """
Apply a diff to a struct, returning the result of applying the new values.
iex> User.builder(%{name: "Alice", age: 30}) |> elem(1) |> GuardedStruct.Diff.apply(%{age: {:changed, 30, 31}})
%User{name: "Alice", age: 31}
Nested struct diffs apply recursively. `:changed` tuples replace the field
with their `new` value. Unknown keys in the diff are silently ignored.
"""
@spec apply(struct() | map(), map()) :: struct() | map()
def apply(%_{} = struct, diff) when is_map(diff) do
Enum.reduce(diff, struct, fn
{key, {:changed, _old, new}}, acc when is_struct(acc) ->
if Map.has_key?(acc, key), do: Map.put(acc, key, new), else: acc
{key, %{} = nested}, acc when is_struct(acc) ->
case Map.get(acc, key) do
%_{} = current -> Map.put(acc, key, __MODULE__.apply(current, nested))
_ -> acc
end
_, acc ->
acc
end)
end
def apply(map, diff) when is_map(map) and is_map(diff) do
Enum.reduce(diff, map, fn
{key, {:changed, _, new}}, acc ->
Map.put(acc, key, new)
{key, %{} = nested}, acc ->
case Map.get(acc, key) do
%{} = sub -> Map.put(acc, key, __MODULE__.apply(sub, nested))
_ -> acc
end
_, acc ->
acc
end)
end
@doc """
Returns `true` if the two structs are equal field-by-field (same as
`diff(a, b) == %{}` but skips work for unchanged fields).
"""
@spec equal?(any(), any()) :: boolean()
def equal?(a, b) do
case diff(a, b) do
:not_comparable -> false
%{} = d -> map_size(d) == 0
end
end
end