lib/tuid_type.ex

defmodule TUID.ParameterizedType do
  @moduledoc """
  Documentation for `TUID.ParameterizedType`.

  ParameterizedType implemention for use with Ecto to use TUIDs (tagged, unique ids) as Ecto Types.

  """

  use Ecto.ParameterizedType

  alias TUID.Base58

  @doc """
  Callback to convert the options specified in the field macro into parameters
  to be used in other callbacks.

  This function is called at compile time, and should raise if invalid values are
  specified. It is idiomatic that the parameters returned from this are a map.
  `field` and `schema` will be injected into the options automatically.

  For example, this schema specification

      schema "my_table" do
        field :my_field, MyParameterizedType, opt1: :foo, opt2: nil
      end

  will result in the call:

      MyParameterizedType.init([schema: "my_table", field: :my_field, opt1: :foo, opt2: nil])

  """
  @impl true
  def init(opts) do
    schema = Keyword.fetch!(opts, :schema)
    field = Keyword.fetch!(opts, :field)
    uniq = Uniq.UUID.init(schema: schema, field: field, version: 7, default: :raw, dump: :raw)

    case opts[:primary_key] do
      true ->
        prefix = Keyword.get(opts, :prefix) || raise "`:prefix` option is required"

        %{
          primary_key: true,
          schema: schema,
          prefix: prefix,
          uniq: uniq
        }

      _any ->
        %{
          schema: schema,
          field: field,
          uniq: uniq
        }
    end
  end

  @impl true
  def type(_params), do: :uuid

  @doc """
  Casts the given input to the ParameterizedType with the given parameters.

  Specifically, convert a TUID, such as `user_C19xa4ANGXSz72USEyc2m` to a binary UUID for storage into the DB.

  For more information on casting, see `c:Ecto.Type.cast/1`.
  """
  @impl true
  def cast(nil, _params), do: {:ok, nil}

  def cast(data, params) do
    with {:ok, prefix, _uuid} <- slug_to_uuid(data, params),
         {prefix, prefix} <- {prefix, prefix(params)} do
      {:ok, data}
    else
      _ -> :error
    end
  end

  defp slug_to_uuid(string, _params) do
    with [prefix, slug] <- String.split(string, "_"),
         {:ok, uuid} <- Base58.decode_uuid(slug) do
      {:ok, prefix, uuid}
    else
      _ -> :error
    end
  end

  defp prefix(%{primary_key: true, prefix: prefix}), do: prefix

  # If we deal with a belongs_to assocation we need to fetch the prefix from
  # the associations schema module
  defp prefix(%{schema: schema, field: field}) do
    %{related: schema, related_key: field} = schema.__schema__(:association, field)
    {:parameterized, __MODULE__, %{prefix: prefix}} = schema.__schema__(:type, field)

    prefix
  end

  @doc """
  Loads the given term into a ParameterizedType.

  It receives a `loader` function in case the parameterized
  type is also a composite type. In order to load the inner
  type, the `loader` must be called with the inner type and
  the inner value as argument.

  For more information on loading, see `c:Ecto.Type.load/1`.
  Note that this callback *will* be called when loading a `nil`
  value, unlike `c:Ecto.Type.load/1`.
  """
  @impl true
  def load(data, loader, params) do
    case Uniq.UUID.load(data, loader, params.uniq) do
      {:ok, nil} -> {:ok, nil}
      {:ok, uuid} -> {:ok, uuid_to_slug(uuid, params)}
      :error -> :error
    end
  end

  defp uuid_to_slug(uuid, params) do
    "#{prefix(params)}_#{Base58.encode_uuid(uuid)}"
  end

  @doc """
  Dumps the given term into an Ecto native type.

  It receives a `dumper` function in case the parameterized
  type is also a composite type. In order to dump the inner
  type, the `dumper` must be called with the inner type and
  the inner value as argument.

  For more information on dumping, see `c:Ecto.Type.dump/1`.
  Note that this callback *will* be called when dumping a `nil`
  value, unlike `c:Ecto.Type.dump/1`.
  """
  @impl true
  def dump(nil, _, _), do: {:ok, nil}

  def dump(slug, dumper, params) do
    case slug_to_uuid(slug, params) do
      {:ok, _prefix, uuid} -> Uniq.UUID.dump(uuid, dumper, params.uniq)
      :error -> :error
    end
  end

  @doc """
  Generates a loaded version of the data.

  This callback is invoked when a parameterized type is given
  to `field` with the `:autogenerate` flag.
  """
  @impl true
  def autogenerate(params) do
    uuid_to_slug(Uniq.UUID.autogenerate(params.uniq), params)
  end

  @doc """
  Dictates how the type should be treated inside embeds.

  For more information on embedding, see `c:Ecto.Type.embed_as/1`
  """
  @impl true
  def embed_as(format, params), do: Uniq.UUID.embed_as(format, params.uniq)

  @doc """
  Returns the underlying schema type for the ParameterizedType.

  For more information on schema types, see `c:Ecto.Type.type/0`
  """
  @impl true
  def equal?(a, b, params), do: Uniq.UUID.equal?(a, b, params.uniq)
end