Skip to main content

lib/terminus_db/diff.ex

defmodule TerminusDB.Diff do
  import Kernel, except: [apply: 2]

  @moduledoc """
  Document diff and patch API for TerminusDB.

  Wraps the `/api/diff`, `/api/patch`, and `/api/apply` endpoints to compare,
  patch, and apply document changes.

  Diffs can be computed between:
  - Two document values (`before` and `after` maps).
  - Branch vs branch, commit vs commit, or branch vs commit (by supplying the
    appropriate resource refs in the `before`/`after` fields).

  ## Quick start

      config =
        TerminusDB.Config.new(endpoint: "http://localhost:6363")
        |> TerminusDB.Config.with_database("mydb")

      # Diff two document values
      {:ok, patch} = TerminusDB.Diff.diff_object(config,
        before: %{"@id" => "Person/Alice", "name" => "Alice"},
        after: %{"@id" => "Person/Alice", "name" => "Alicia"}
      )

      # Apply a patch to a branch
      {:ok, _} = TerminusDB.Diff.patch_resource(config,
        patch: patch, message: "update name", author: "admin"
      )

  """

  alias TerminusDB.{Client, Config, Error, Patch}
  alias TerminusDB.Client.Params

  @type compare_opt ::
          {:before, map()}
          | {:after, map()}
          | {:keep, map()}
          | {:organization, String.t()}

  defp diff_path(config, opts) do
    org = opts[:organization] || config.organization
    db = config.database || raise Error, reason: :http, message: "no database scoped in config"
    "diff/#{org}/#{db}"
  end

  @doc """
  Compares two document states and returns a structured diff patch.

  The `before` and `after` values can be:
  - Document maps (with `@id` and fields) for a value-level diff.
  - Resource references (e.g. `"admin/mydb/local/branch/main"`) for a
    branch/commit-level diff.

  ## Options

  - `:before` (required) - the "before" document or resource ref.
  - `:after` (required) - the "after" document or resource ref.
  - `:keep` - a map of fields to preserve in the diff (e.g. `%{"@id" => true}`).
  - `:organization` - overrides `config.organization`.

  ## Examples

  Diff two document values:

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req ->
      ...>     {req, Req.Response.new(status: 200, body: %{"name" => %{"@op" => "ValueSwap", "@before" => "Alice", "@after" => "Alicia"}})}
      ...>   end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, patch} = TerminusDB.Diff.compare(config,
      ...>   before: %{"@id" => "Person/Alice", "name" => "Alice"},
      ...>   after: %{"@id" => "Person/Alice", "name" => "Alicia"}
      ...> )
      iex> patch["name"]["@op"]
      "ValueSwap"

  Diff two branches:

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{})} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, _} = TerminusDB.Diff.compare(config,
      ...>   before: "admin/mydb/local/branch/main",
      ...>   after: "admin/mydb/local/branch/feature"
      ...> )
      :ok

  """
  @spec compare(Config.t(), [compare_opt()]) :: {:ok, map()} | {:error, Error.t()}
  def compare(config, opts \\ []) do
    path = diff_path(config, opts)

    before_value = Keyword.fetch!(opts, :before)
    after_value = Keyword.fetch!(opts, :after)

    body =
      Params.maybe_put(%{"before" => before_value, "after" => after_value}, "keep", opts[:keep])

    Client.request(config, :post, path, json: body, area: :diff)
  end

  @doc """
  Compares two document states, or raises `TerminusDB.Error`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"name" => %{"@op" => "ValueSwap"}})} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> TerminusDB.Diff.compare!(config,
      ...>   before: %{"name" => "Alice"},
      ...>   after: %{"name" => "Alicia"}
      ...> )
      %{"name" => %{"@op" => "ValueSwap"}}

  """
  @spec compare!(Config.t(), [compare_opt()]) :: map()
  def compare!(config, opts \\ []) do
    case compare(config, opts) do
      {:ok, body} -> body
      {:error, error} -> raise error
    end
  end

  @type diff_object_opt ::
          {:before, map()}
          | {:after, map()}
          | {:keep, map()}
          | {:organization, String.t()}
          | {:repo, String.t()}

  @doc """
  Diffs two concrete document objects and returns a `TerminusDB.Patch` struct.

  ## Options

  - `:before` (required) - the "before" document map.
  - `:after` (required) - the "after" document map.
  - `:keep` - a map of fields to preserve in the diff.
  - `:organization` - overrides `config.organization`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req ->
      ...>     {req, Req.Response.new(status: 200, body: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}})}
      ...>   end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, patch} = TerminusDB.Diff.diff_object(config,
      ...>   before: %{"@id" => "Person/1", "name" => "old"},
      ...>   after: %{"@id" => "Person/1", "name" => "new"}
      ...> )
      iex> patch.content["name"]["@after"]
      "new"

  """
  @spec diff_object(Config.t(), [diff_object_opt()]) ::
          {:ok, Patch.t()} | {:error, Error.t()}
  def diff_object(config, opts \\ []) do
    path = diff_resource_path(config, opts)

    before_value = Keyword.fetch!(opts, :before)
    after_value = Keyword.fetch!(opts, :after)

    body =
      Params.maybe_put(%{"before" => before_value, "after" => after_value}, "keep", opts[:keep])

    case Client.request(config, :post, path, json: body, area: :diff) do
      {:ok, patch_content} -> {:ok, %Patch{content: patch_content}}
      {:error, _} = error -> error
    end
  end

  @doc """
  Diffs two concrete document objects, or raises.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req ->
      ...>     {req, Req.Response.new(status: 200, body: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}})}
      ...>   end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> patch = TerminusDB.Diff.diff_object!(config,
      ...>   before: %{"name" => "old"},
      ...>   after: %{"name" => "new"}
      ...> )
      iex> patch.content["name"]["@after"]
      "new"

  """
  @spec diff_object!(Config.t(), [diff_object_opt()]) :: Patch.t()
  def diff_object!(config, opts \\ []) do
    case diff_object(config, opts) do
      {:ok, patch} -> patch
      {:error, error} -> raise error
    end
  end

  @type diff_version_opt ::
          {:before_version, String.t()}
          | {:after_version, String.t()}
          | {:organization, String.t()}
          | {:repo, String.t()}

  @doc """
  Diffs two commit/branch versions and returns a `TerminusDB.Patch` struct.

  ## Options

  - `:before_version` (required) - the before commit/branch descriptor.
  - `:after_version` (required) - the after commit/branch descriptor.
  - `:organization` - overrides `config.organization`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{})} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, patch} = TerminusDB.Diff.diff_version(config,
      ...>   before_version: "admin/mydb/local/branch/main",
      ...>   after_version: "admin/mydb/local/branch/feature"
      ...> )
      iex> patch.content
      %{}

  """
  @spec diff_version(Config.t(), [diff_version_opt()]) ::
          {:ok, Patch.t()} | {:error, Error.t()}
  def diff_version(config, opts \\ []) do
    path = diff_resource_path(config, opts)

    body = %{
      "before_data_version" => Keyword.fetch!(opts, :before_version),
      "after_data_version" => Keyword.fetch!(opts, :after_version)
    }

    case Client.request(config, :post, path, json: body, area: :diff) do
      {:ok, patch_content} -> {:ok, %Patch{content: patch_content}}
      {:error, _} = error -> error
    end
  end

  @doc """
  Diffs two versions, or raises.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{})} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> patch = TerminusDB.Diff.diff_version!(config,
      ...>   before_version: "admin/mydb/local/branch/main",
      ...>   after_version: "admin/mydb/local/branch/feature"
      ...> )
      iex> patch.content
      %{}

  """
  @spec diff_version!(Config.t(), [diff_version_opt()]) :: Patch.t()
  def diff_version!(config, opts \\ []) do
    case diff_version(config, opts) do
      {:ok, patch} -> patch
      {:error, error} -> raise error
    end
  end

  @type patch_opt ::
          {:organization, String.t()}

  @doc """
  Applies a patch to a "before" object and returns the "after" object (no
  commit).

  ## Options

  - `:organization` - overrides `config.organization`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"@id" => "Person/1", "name" => "new"})} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, after_obj} = TerminusDB.Diff.patch(config,
      ...>   before: %{"@id" => "Person/1", "name" => "old"},
      ...>   patch: %{"name" => %{"@op" => "SwapValue", "@before" => "old", "@after" => "new"}}
      ...> )
      iex> after_obj["name"]
      "new"

  """
  @spec patch(Config.t(), [patch_opt() | {:before, map()} | {:patch, map()}]) ::
          {:ok, map()} | {:error, Error.t()}
  def patch(config, opts \\ []) do
    before_value = Keyword.fetch!(opts, :before)
    patch_value = Keyword.fetch!(opts, :patch)

    body = %{"before" => before_value, "patch" => patch_value}

    Client.request(config, :post, "patch", json: body, area: :diff)
  end

  @doc """
  Applies a patch, or raises.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"name" => "new"})} end
      ...> )
      iex> TerminusDB.Diff.patch!(config,
      ...>   before: %{"name" => "old"},
      ...>   patch: %{"name" => %{"@op" => "SwapValue", "@after" => "new"}}
      ...> )
      %{"name" => "new"}

  """
  @spec patch!(Config.t(), [patch_opt() | {:before, map()} | {:patch, map()}]) :: map()
  def patch!(config, opts \\ []) do
    case patch(config, opts) do
      {:ok, body} -> body
      {:error, error} -> raise error
    end
  end

  @type patch_resource_opt ::
          {:patch, map()}
          | {:message, String.t()}
          | {:author, String.t()}
          | {:match_final_state, boolean()}
          | {:organization, String.t()}
          | {:repo, String.t()}

  @doc """
  Applies a patch to a branch resource (commits the change).

  ## Options

  - `:patch` (required) - the patch content.
  - `:message` - commit message.
  - `:author` - commit author.
  - `:match_final_state` - boolean.
  - `:organization` - overrides `config.organization`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, resp} = TerminusDB.Diff.patch_resource(config,
      ...>   patch: %{"name" => %{"@op" => "SwapValue", "@after" => "new"}},
      ...>   author: "admin", message: "update"
      ...> )
      iex> resp["api:status"]
      "api:success"

  """
  @spec patch_resource(Config.t(), [patch_resource_opt()]) ::
          {:ok, map()} | {:error, Error.t()}
  def patch_resource(config, opts \\ []) do
    path = patch_resource_path(config, opts)
    patch_value = Keyword.fetch!(opts, :patch)

    body =
      %{"patch" => patch_value}
      |> Params.maybe_put("message", opts[:message])
      |> Params.maybe_put("author", opts[:author])
      |> Params.maybe_put("match_final_state", opts[:match_final_state])

    Client.request(config, :post, path, json: body, area: :diff)
  end

  @doc """
  Applies a patch to a branch resource, or raises.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> TerminusDB.Diff.patch_resource!(config, patch: %{})
      %{"api:status" => "api:success"}

  """
  @spec patch_resource!(Config.t(), [patch_resource_opt()]) :: map()
  def patch_resource!(config, opts \\ []) do
    case patch_resource(config, opts) do
      {:ok, body} -> body
      {:error, error} -> raise error
    end
  end

  @type apply_opt ::
          {:before_version, String.t()}
          | {:after_version, String.t()}
          | {:message, String.t()}
          | {:author, String.t()}
          | {:organization, String.t()}
          | {:repo, String.t()}

  @doc """
  Diffs two commits and applies the changes onto a branch.

  ## Options

  - `:before_version` (required) - the before commit descriptor.
  - `:after_version` (required) - the after commit descriptor.
  - `:message` - commit message.
  - `:author` - commit author.
  - `:organization` - overrides `config.organization`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, resp} = TerminusDB.Diff.apply(config,
      ...>   before_version: "admin/mydb/local/commit/abc",
      ...>   after_version: "admin/mydb/local/commit/def",
      ...>   author: "admin", message: "apply"
      ...> )
      iex> resp["api:status"]
      "api:success"

  """
  @spec apply(Config.t(), [apply_opt()]) ::
          {:ok, map()} | {:error, Error.t()}
  def apply(config, opts \\ []) do
    path = apply_path(config, opts)

    commit_info =
      %{}
      |> Params.maybe_put("author", opts[:author])
      |> Params.maybe_put("message", opts[:message])

    body =
      Params.maybe_put(
        %{
          "before_commit" => Keyword.fetch!(opts, :before_version),
          "after_commit" => Keyword.fetch!(opts, :after_version)
        },
        "commit_info",
        commit_info
      )

    Client.request(config, :post, path, json: body, area: :diff)
  end

  @doc """
  Applies a diff to a branch, or raises.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> TerminusDB.Diff.apply!(config,
      ...>   before_version: "admin/mydb/local/commit/abc",
      ...>   after_version: "admin/mydb/local/commit/def"
      ...> )
      %{"api:status" => "api:success"}

  """
  @spec apply!(Config.t(), [apply_opt()]) :: map()
  def apply!(config, opts \\ []) do
    case apply(config, opts) do
      {:ok, body} -> body
      {:error, error} -> raise error
    end
  end

  defp diff_resource_path(config, opts) do
    org = opts[:organization] || config.organization
    db = config.database || raise Error, reason: :http, message: "no database scoped in config"
    repo = opts[:repo] || config.repo
    branch = config.branch
    "diff/#{org}/#{db}/#{repo}/branch/#{branch}"
  end

  defp patch_resource_path(config, opts) do
    org = opts[:organization] || config.organization
    db = config.database || raise Error, reason: :http, message: "no database scoped in config"
    repo = opts[:repo] || config.repo
    branch = config.branch
    "patch/#{org}/#{db}/#{repo}/branch/#{branch}"
  end

  defp apply_path(config, opts) do
    org = opts[:organization] || config.organization
    db = config.database || raise Error, reason: :http, message: "no database scoped in config"
    repo = opts[:repo] || config.repo
    branch = config.branch
    "apply/#{org}/#{db}/#{repo}/branch/#{branch}"
  end
end