Skip to main content

lib/terminus_db/patch.ex

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