Skip to main content

lib/terminus_db/schema.ex

defmodule TerminusDB.Schema do
  @moduledoc """
  Schema frame API for TerminusDB.

  Wraps the `/api/schema` endpoint, which returns the class frame for a class or
  all classes for a database's schema. A "frame" is a JSON-LD description of a
  schema class: its properties, types, key strategy, and documentation.

  All functions require a `TerminusDB.Config` scoped to a database (via
  `TerminusDB.Config.with_database/2`).

  ## Quick start

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

      # Get the frame for a specific class
      {:ok, frame} = TerminusDB.Schema.frame(config, "Person")
      # => %{"@type" => "Class", "name" => "xsd:string", ...}

      # Get all class frames
      {:ok, all} = TerminusDB.Schema.all(config)
      # => %{"Person" => %{"@type" => "Class", ...}, "Room" => %{...}}

  """

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

  @type frame_opt ::
          {:compress_ids, boolean()}
          | {:expand_abstract, boolean()}
          | {:organization, String.t()}

  defp schema_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
    "schema/#{org}/#{db}/#{repo}/branch/#{branch}"
  end

  @doc """
  Returns the class frame for a specific class `class_name`, or all class frames
  if `class_name` is `nil`.

  ## Options

  - `:compress_ids` — compress the URLs returned using prefixes (default `true`).
  - `:expand_abstract` — expand abstract classes into lists of concrete classes
    in frame options (default `true`).
  - `:organization` — overrides `config.organization`.

  ## Examples

  Get the frame for a specific class:

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req ->
      ...>     {req, Req.Response.new(status: 200, body: %{"@type" => "Class", "name" => "xsd:string"})}
      ...>   end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, frame} = TerminusDB.Schema.frame(config, "Person")
      iex> frame["name"]
      "xsd:string"

  Get all class frames (pass `nil` or omit `class_name`):

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req ->
      ...>     {req, Req.Response.new(status: 200, body: %{"Person" => %{"@type" => "Class"}, "Room" => %{"@type" => "Class"}})}
      ...>   end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, all} = TerminusDB.Schema.frame(config)
      iex> Map.keys(all) |> Enum.sort()
      ["Person", "Room"]

  """
  @spec frame(Config.t(), String.t() | nil, [frame_opt()]) ::
          {:ok, map()} | {:error, Error.t()}
  def frame(config, class_name \\ nil, opts \\ []) do
    path = schema_path(config, opts)

    params =
      Params.bool_param(:compress_ids, opts[:compress_ids]) ++
        Params.bool_param(:expand_abstract, opts[:expand_abstract]) ++
        Params.flag_param(:type, class_name)

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

  @doc """
  Returns the class frame, or raises `TerminusDB.Error`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"@type" => "Class", "name" => "xsd:string"})} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> TerminusDB.Schema.frame!(config, "Person")
      %{"@type" => "Class", "name" => "xsd:string"}

  """
  @spec frame!(Config.t(), String.t() | nil, [frame_opt()]) :: map()
  def frame!(config, class_name \\ nil, opts \\ []) do
    case frame(config, class_name, opts) do
      {:ok, body} -> body
      {:error, error} -> raise error
    end
  end

  @doc """
  Returns all class frames for the database's schema.

  Equivalent to `frame(config, nil, opts)`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req ->
      ...>     {req, Req.Response.new(status: 200, body: %{"@context" => %{"@type" => "Context"}, "Person" => %{"@type" => "Class"}})}
      ...>   end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, all} = TerminusDB.Schema.all(config)
      iex> Map.keys(all)
      ["Person"]

  """
  @spec all(Config.t(), [frame_opt()]) :: {:ok, map()} | {:error, Error.t()}
  def all(config, opts \\ []) do
    case frame(config, nil, opts) do
      {:ok, body} ->
        {:ok, filter_class_frames(body)}

      {:error, _} = error ->
        error
    end
  end

  defp filter_class_frames(body) do
    Map.reject(body, fn {key, _} -> String.starts_with?(key, "@") end)
  end

  @doc """
  Returns all class frames, or raises `TerminusDB.Error`.

  ## Examples

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

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

  # For boolean params where `false` is a meaningful value (not a default to
  # omit), we pass it through explicitly via Params.bool_param/2.
end