defmodule TerminusDB.Patch do
@moduledoc """
A JSON-LD patch container for TerminusDB document diffs.
A `Patch` holds the raw JSON-LD patch content produced by `TerminusDB.Diff`
operations. It provides convenience projections for extracting the "before"
and "after" (update) states from `SwapValue` operations.
## Quick start
{:ok, patch} = TerminusDB.Diff.diff_object(config,
before: %{"@id" => "Person/1", "name" => "old"},
after: %{"@id" => "Person/1", "name" => "new"}
)
patch.update # => %{"name" => "new"}
patch.before # => %{"name" => "old"}
"""
@enforce_keys [:content]
defstruct [:content]
@type t :: %__MODULE__{content: map() | [map()]}
@doc """
Parses a JSON string into a `%Patch{}`.
## Examples
iex> {:ok, patch} = TerminusDB.Patch.from_json(~s({"name": {"@op": "SwapValue", "@before": "old", "@after": "new"}}))
iex> patch.content["name"]["@after"]
"new"
"""
@spec from_json(String.t()) :: {:ok, t()} | {:error, term()}
def from_json(json_string) do
case Jason.decode(json_string) do
{:ok, content} when is_map(content) -> {:ok, %__MODULE__{content: content}}
{:ok, content} when is_list(content) -> {:ok, %__MODULE__{content: content}}
{:error, _} = error -> error
end
end
@doc """
Parses a JSON string into a `%Patch{}`, or raises.
## Examples
iex> patch = TerminusDB.Patch.from_json!(~s({"name": {"@op": "SwapValue", "@before": "old", "@after": "new"}}))
iex> patch.content["name"]["@before"]
"old"
"""
@spec from_json!(String.t()) :: t()
def from_json!(json_string) do
case from_json(json_string) do
{:ok, patch} -> patch
{:error, error} -> raise ArgumentError, "invalid JSON: #{inspect(error)}"
end
end
@doc """
Serializes a `%Patch{}` to a JSON string.
## Examples
iex> patch = %TerminusDB.Patch{content: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}}}
iex> json = TerminusDB.Patch.to_json(patch)
iex> is_binary(json)
true
"""
@spec to_json(t()) :: String.t()
def to_json(%__MODULE__{content: content}) do
Jason.encode!(content)
end
@doc """
Extracts the "after" (updated) values from `SwapValue` operations,
recursively.
Only fields containing `SwapValue` operations are included in the result.
Non-`SwapValue` fields are omitted. This is intentionally asymmetric with
`before/1`, which includes all fields (preserving non-`SwapValue` values)
to allow full reconstruction of the "before" state.
## Examples
iex> patch = %TerminusDB.Patch{content: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}}}
iex> TerminusDB.Patch.update(patch)
%{"name" => "new"}
"""
@spec update(t()) :: map()
def update(%__MODULE__{content: content}) do
extract_after(content)
end
@doc """
Extracts the "before" values from `SwapValue` operations, recursively.
Includes all fields from the patch content, preserving non-`SwapValue`
values as-is. This is intentionally asymmetric with `update/1`, which
only includes `SwapValue` fields — `before/1` aims to reconstruct the
full "before" state while `update/1` shows only what changed.
## Examples
iex> patch = %TerminusDB.Patch{content: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}}}
iex> TerminusDB.Patch.before(patch)
%{"name" => "old"}
"""
@spec before(t()) :: map()
def before(%__MODULE__{content: content}) do
extract_before(content)
end
@doc """
Creates a deep copy of the patch.
## Examples
iex> patch = %TerminusDB.Patch{content: %{"name" => "value"}}
iex> copy = TerminusDB.Patch.copy(patch)
iex> copy == patch
true
"""
@spec copy(t()) :: t()
def copy(%__MODULE__{content: content}) do
%__MODULE__{content: deep_copy(content)}
end
defp extract_after(item) when is_map(item) do
case item do
%{"@op" => "SwapValue", "@after" => after_val} ->
after_val
_ ->
Enum.reduce(item, %{}, fn {key, val}, acc ->
case val do
%{"@op" => "SwapValue", "@after" => after_val} ->
Map.put(acc, key, after_val)
v when is_map(v) ->
extracted = extract_after(v)
if map_size(extracted) > 0 do
Map.put(acc, key, extracted)
else
acc
end
_ ->
acc
end
end)
end
end
defp extract_after(_), do: %{}
defp extract_before(item) when is_map(item) do
Enum.reduce(item, %{}, fn {key, val}, acc ->
case val do
%{"@op" => "SwapValue", "@before" => before_val} ->
Map.put(acc, key, before_val)
v when is_map(v) ->
extracted = extract_before(v)
if map_size(extracted) > 0 do
Map.put(acc, key, extracted)
else
acc
end
v ->
Map.put(acc, key, v)
end
end)
end
defp extract_before(_), do: %{}
defp deep_copy(term) when is_map(term) do
Map.new(term, fn {k, v} -> {k, deep_copy(v)} end)
end
defp deep_copy(term) when is_list(term) do
Enum.map(term, &deep_copy/1)
end
defp deep_copy(term), do: term
end