Skip to main content

lib/terminus_db/document.ex

defmodule TerminusDB.Document do
  @moduledoc """
  Document CRUD and query API for TerminusDB.

  Wraps the `/api/document/{path}` endpoints. A "document" is a JSON object
  conforming to a schema class, stored as linked triples. Documents can be
  inserted, retrieved, queried by template, replaced, and deleted.

  All functions require a `TerminusDB.Config` scoped to a database (via
  `TerminusDB.Config.with_database/2`). The organization defaults to
  `config.organization` but can be overridden per call via the `:organization`
  option.

  ## Graph types

  Operations target the `:instance` graph (data) by default. Pass
  `graph_type: :schema` to operate on the schema graph (schema documents are
  documents too).

  ## Quick start

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

      # Insert a schema (Class document)
      {:ok, _} =
        TerminusDB.Document.insert(config,
          %{"@type" => "Class", "@id" => "Person", "name" => "xsd:string"},
          author: "admin", message: "add schema", graph_type: :schema
        )

      # Insert a document
      {:ok, _} =
        TerminusDB.Document.insert(config,
          %{"@type" => "Person", "name" => "Alice"},
          author: "admin", message: "add Alice"
        )

      # Retrieve documents by type
      {:ok, docs} = TerminusDB.Document.get(config, type: "Person", as_list: true)

      # Query by template
      {:ok, matches} =
        TerminusDB.Document.query(config, %{"@type" => "Person", "name" => "Alice"})

      # Stream large result sets
      TerminusDB.Document.stream(config, type: "Person")
      |> Stream.each(&IO.inspect/1)
      |> Stream.run()

  """

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

  @type graph_type :: :instance | :schema

  @type insert_opt ::
          {:author, String.t()}
          | {:message, String.t()}
          | {:graph_type, graph_type()}
          | {:full_replace, boolean()}
          | {:raw_json, boolean()}
          | {:organization, String.t()}

  @type get_opt ::
          {:graph_type, graph_type()}
          | {:id, String.t()}
          | {:type, String.t()}
          | {:skip, non_neg_integer()}
          | {:count, pos_integer()}
          | {:as_list, boolean()}
          | {:unfold, boolean()}
          | {:minimized, boolean()}
          | {:compress_ids, boolean()}
          | {:organization, String.t()}

  @type update_opt ::
          {:author, String.t()}
          | {:message, String.t()}
          | {:graph_type, graph_type()}
          | {:create, boolean()}
          | {:raw_json, boolean()}
          | {:organization, String.t()}

  @type delete_opt ::
          {:author, String.t()}
          | {:message, String.t()}
          | {:graph_type, graph_type()}
          | {:id, String.t()}
          | {:nuke, boolean()}
          | {:organization, String.t()}

  @type query_opt ::
          {:graph_type, graph_type()}
          | {:skip, non_neg_integer()}
          | {:count, pos_integer()}
          | {:as_list, boolean()}
          | {:organization, String.t()}

  # Path building -------------------------------------------------------------

  defp document_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 = opts[:branch] || config.branch
    "document/#{org}/#{db}/#{repo}/branch/#{branch}"
  end

  defp graph_type_param(opts), do: [graph_type: Atom.to_string(opts[:graph_type] || :instance)]

  # Public API ----------------------------------------------------------------

  @doc """
  Inserts one or more documents into the database.

  `document` can be a single map or a list of maps. The response body from
  TerminusDB (the inserted document IDs) is returned.

  ## Options

  - `:author` — commit author (required by the API).
  - `:message` — commit message (required by the API).
  - `:graph_type` — `:instance` (default) or `:schema`.
  - `:full_replace` — if `true`, delete all existing documents before inserting.
  - `:raw_json` — if `true`, insert as untyped `sys:JSONDocument` (no schema check).
  - `: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/Alice"}])}
      ...>   end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, ids} = TerminusDB.Document.insert(config,
      ...>   %{"@type" => "Person", "name" => "Alice"},
      ...>   author: "admin", message: "add Alice"
      ...> )
      iex> ids
      [%{"@id" => "Person/Alice"}]

  Inserting a list of documents:

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"@id" => "Person/Alice"}, %{"@id" => "Person/Bob"}])} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, ids} = TerminusDB.Document.insert(config, [
      ...>   %{"@type" => "Person", "name" => "Alice"},
      ...>   %{"@type" => "Person", "name" => "Bob"}
      ...> ], author: "admin", message: "add people")
      iex> length(ids)
      2

  """
  @spec insert(Config.t(), map() | [map()], [insert_opt()]) ::
          {:ok, term()} | {:error, Error.t()}
  def insert(config, document, opts \\ []) do
    path = document_path(config, opts)

    params =
      graph_type_param(opts) ++
        commit_params(opts) ++
        Params.flag_param(:full_replace, opts[:full_replace]) ++
        Params.flag_param(:raw_json, opts[:raw_json])

    Client.request(config, :post, path, json: document, params: params, area: :document)
  end

  @doc """
  Inserts documents, returning the response body or raising `TerminusDB.Error`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"@id" => "Person/Alice"}])} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> TerminusDB.Document.insert!(config, %{"@type" => "Person", "name" => "Alice"},
      ...>   author: "admin", message: "add"
      ...> )
      [%{"@id" => "Person/Alice"}]

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

  @doc """
  Retrieves documents from the database.

  With `:id`, returns a single document. Without `:id`, returns all documents
  (or those of a `:type`). By default TerminusDB returns concatenated JSON; pass
  `as_list: true` to get a JSON array decoded into a list of maps.

  ## Options

  - `:id` — retrieve a specific document by ID.
  - `:type` — retrieve documents of a specific type.
  - `:graph_type` — `:instance` (default) or `:schema`.
  - `:skip` — number of documents to skip (default `0`).
  - `:count` — max number of documents to return.
  - `:as_list` — request a JSON array instead of concatenated JSON.
  - `:unfold` — join referenced documents (default `true`).
  - `:minimized` — minify output (default `true`).
  - `:compress_ids` — compress IDs using prefixes (default `true`).
  - `:organization` — overrides `config.organization`.

  ## Examples

  Get a single document by ID:

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"@id" => "Person/Alice", "name" => "Alice"})} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, person} = TerminusDB.Document.get(config, id: "Person/Alice", as_list: false)
      iex> person["name"]
      "Alice"

  Get all documents of a type:

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"@id" => "Person/Alice"}, %{"@id" => "Person/Bob"}])} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, docs} = TerminusDB.Document.get(config, type: "Person", as_list: true)
      iex> length(docs)
      2

  """
  @spec get(Config.t(), [get_opt()]) :: {:ok, term()} | {:error, Error.t()}
  def get(config, opts \\ []) do
    path = document_path(config, opts)

    params =
      graph_type_param(opts) ++
        get_params(opts)

    Client.request(config, :get, path, params: params, area: :document)
  end

  @doc """
  Retrieves documents, or raises `TerminusDB.Error`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"@id" => "Person/Alice"})} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> TerminusDB.Document.get!(config, id: "Person/Alice")
      %{"@id" => "Person/Alice"}

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

  @doc """
  Queries documents by a template.

  `template` is a map describing the shape of documents to match, e.g.
  `%{"@type" => "Person", "age" => 30}`. TerminusDB returns all documents that
  match the template.

  ## Options

  - `:graph_type` — `:instance` (default) or `:schema`.
  - `:skip`, `:count` — pagination.
  - `:as_list` — return a JSON array.
  - `:organization` — overrides `config.organization`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"@type" => "Person", "name" => "Alice", "age" => 30}])} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, matches} = TerminusDB.Document.query(config, %{"@type" => "Person", "age" => 30})
      iex> hd(matches)["name"]
      "Alice"

  """
  @spec query(Config.t(), map(), [query_opt()]) :: {:ok, term()} | {:error, Error.t()}
  def query(config, template, opts \\ []) do
    path = document_path(config, opts)

    body =
      Params.maybe_put(
        %{
          "query" => template,
          "graph_type" => Atom.to_string(opts[:graph_type] || :instance),
          "skip" => opts[:skip] || 0
        },
        "count",
        opts[:count]
      )

    params = [{:as_list, true}]

    Client.request(config, :post, path,
      json: body,
      params: params,
      headers: [{"x-http-method-override", "GET"}],
      area: :document
    )
  end

  @doc """
  Queries documents by template, 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" => "Alice"}])} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> TerminusDB.Document.query!(config, %{"name" => "Alice"})
      [%{"name" => "Alice"}]

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

  @doc """
  Replaces one or more existing documents.

  If a document does not exist, an error is returned unless `create: true` is
  set, in which case it is inserted.

  ## Options

  - `:author`, `:message` — commit metadata (required by the API).
  - `:graph_type` — `:instance` (default) or `:schema`.
  - `:create` — insert if the document does not exist (default `false`).
  - `:raw_json` — treat as untyped JSON (default `false`).
  - `: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/Alice", "name" => "Alicia"})} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, updated} = TerminusDB.Document.replace(config,
      ...>   %{"@id" => "Person/Alice", "name" => "Alicia"},
      ...>   author: "admin", message: "rename Alice"
      ...> )
      iex> updated["name"]
      "Alicia"

  """
  @spec replace(Config.t(), map() | [map()], [update_opt()]) ::
          {:ok, term()} | {:error, Error.t()}
  def replace(config, document, opts \\ []) do
    path = document_path(config, opts)

    params =
      graph_type_param(opts) ++
        commit_params(opts) ++
        Params.flag_param(:create, opts[:create]) ++
        Params.flag_param(:raw_json, opts[:raw_json])

    Client.request(config, :put, path, json: document, params: params, area: :document)
  end

  @doc """
  Replaces documents, or raises `TerminusDB.Error`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"@id" => "Person/Alice"})} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> TerminusDB.Document.replace!(config, %{"@id" => "Person/Alice", "name" => "Alicia"},
      ...>   author: "admin", message: "rename"
      ...> )
      %{"@id" => "Person/Alice"}

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

  @doc """
  Deletes documents from the database.

  With `:id`, deletes a single document. With `:nuke`, deletes all documents at
  the resource location. Without either, a body containing a list of IDs must
  be posted (not yet supported by this function; use `:id` or `:nuke`).

  ## Options

  - `:author`, `:message` — commit metadata (required by the API).
  - `:graph_type` — `:instance` (default) or `:schema`.
  - `:id` — delete a specific document by ID.
  - `:nuke` — delete all documents (dangerous).
  - `: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.Document.delete(config,
      ...>   id: "Person/Alice", author: "admin", message: "remove Alice"
      ...> )
      iex> resp["api:status"]
      "api:success"

  """
  @spec delete(Config.t(), [delete_opt()]) :: {:ok, term()} | {:error, Error.t()}
  def delete(config, opts \\ []) do
    path = document_path(config, opts)

    params =
      graph_type_param(opts) ++
        commit_params(opts) ++
        Params.flag_param(:nuke, opts[:nuke]) ++
        Params.flag_param(:id, opts[:id])

    Client.request(config, :delete, path, params: params, area: :document)
  end

  @doc """
  Deletes documents, or raises `TerminusDB.Error`.

  ## 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.Document.delete!(config, id: "Person/Alice", author: "a", message: "m")
      %{"api:status" => "api:success"}

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

  @doc """
  Returns a `Stream` of documents, decoded incrementally from the response.

  Uses Req's response streaming (`into: :self`) and the concatenated-JSON
  splitter in `TerminusDB.Streaming` to yield documents one at a time with
  constant memory. Useful for large result sets.

  ## Options

  Accepts the same options as `get/2` (`:type`, `:graph_type`, `:skip`,
  `:count`, etc.).

  ## Examples

      # Stream all Person documents and process one at a time:
      TerminusDB.Document.stream(config, type: "Person")
      |> Stream.each(&IO.inspect/1)
      |> Stream.run()

  """
  @spec stream(Config.t(), [get_opt()]) :: Enumerable.t()
  def stream(config, opts \\ []) do
    path = document_path(config, opts)

    params =
      graph_type_param(opts) ++
        get_params(opts)

    case Client.request_response(config, :get, path,
           params: params,
           into: :self,
           area: :document
         ) do
      {:ok, resp} ->
        TerminusDB.Streaming.document_stream(resp, timeout: config.receive_timeout)

      {:error, error} ->
        raise error
    end
  end

  # Helpers -------------------------------------------------------------------

  defp commit_params(opts) do
    Params.flag_param(:author, opts[:author]) ++ Params.flag_param(:message, opts[:message])
  end

  defp get_params(opts) do
    # as_list/unfold/minimized/compress_ids are tri-state: the server defaults
    # them to true, so an explicit false must be sent to override.
    Params.flag_param(:id, opts[:id]) ++
      Params.flag_param(:type, opts[:type]) ++
      Params.flag_param(:skip, opts[:skip]) ++
      Params.flag_param(:count, opts[:count]) ++
      Params.bool_param(:as_list, opts[:as_list]) ++
      Params.bool_param(:unfold, opts[:unfold]) ++
      Params.bool_param(:minimized, opts[:minimized]) ++
      Params.bool_param(:compress_ids, opts[:compress_ids])
  end
end