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