lib/ecto/hashids.ex

if Code.ensure_loaded?(Hashids) do
  defmodule Ecto.Hashids do
    @moduledoc """
    A custom type leverages the generated sequence integer value to
    encode a hashid string for the primary key, recommend to use this for
    the partition key (the first defined primary key).

    Define `:hashids` type to the primary key of schema, for example:

        defmodule MySchema do
          use EctoTablestore.Schema

          schema "table_name" do
            field(:id, Ecto.Hashids, primary_key: true, autogenerate: true)
          end

        end

    In the above case, there will try to find a schema `:hashids` configuration from
    the application environment:

        config :ecto_tablestore,
          hashids: [
            {MySchema, [salt: "...", min_len: ..., alphabet: "..."]}
          ]

    If the `:hashids` option of the application environment is not defined, there will use default
    options("`[]`") to `Hashids.new/1`.

    We can also set the options when define the `:hashids` type to the primary key,
    for example:

        field(:id, :hashids,
          primary_key: true,
          autogenerate: true
          hashids: [salt: "...", min_len: ..., alphabet: "..."]
        )

    Once the above `:hashids` options defined in the `:id` field of schema, there will always use them
    to encode a hashid, meanwhile the application runtime environment definition will not affect them.

    Please see the options of `Hashids.new/1` for details.
    """

    use Ecto.ParameterizedType

    @hashids_opts [:alphabet, :min_len, :salt]

    @impl true
    def type(_options) do
      # define type as `:binary_id` there will be processed as
      # `autogenerate_id` by Ecto, and can properly return the primary key
      # with the expected value into the struct after insert.
      :binary_id
    end

    @impl true
    def init(opts) do
      opts
      |> Keyword.get(:hashids, [])
      |> Keyword.take(@hashids_opts)
      |> Keyword.put(:schema, opts[:schema])
    end

    @impl true
    def cast(value, _options) when is_bitstring(value) do
      {:ok, value}
    end

    @impl true
    def load(value, _loader, _options) do
      {:ok, value}
    end

    @impl true
    def dump(nil, _, _), do: {:ok, nil}
    def dump(value, _dumper, options) when is_integer(value) and value >= 0 do
      value = options |> new_hashids() |> Hashids.encode(value)
      {:ok, value}
    end
    def dump(value, _dumper, _options) when is_bitstring(value) do
      # already hashids encoded case but this dump callback still invoked by Ecto
      # for example, insert and then update it with an generated hashids
      {:ok, value}
    end

    defp new_hashids(opts) do
      hashids_opts = Keyword.take(opts, @hashids_opts)

      if hashids_opts == [] do
        opts[:schema]
        |> fetch_env_hashids_opts!()
        |> opts_to_hashids()
      else
        opts_to_hashids(hashids_opts)
      end
    end

    defp fetch_env_hashids_opts!(schema) do
      opts =
        :ecto_tablestore
        |> Application.get_env(:hashids, [])
        |> Keyword.get(schema, [])

      if not is_list(opts),
        do: raise_invalid_opts(opts)

      opts
    end

    defp opts_to_hashids(opts) when is_list(opts) do
      Hashids.new(opts)
    end
    defp opts_to_hashids(opts) do
      raise_invalid_opts(opts)
    end

    defp raise_invalid_opts(opts) do
      raise ArgumentError, message: "expect a keyword as options to Hashids.new/1, but got: #{inspect(opts)}"
    end
  end
end