lib/ecto/replaceable_string.ex

defmodule Ecto.ReplaceableString do
  @moduledoc """
  A custom type that replace some terms for strings.

  `#{inspect(__MODULE__)}` can be used when you want to replace some terms on dump/load
  a field, or even both included, for example:

  Before a field persisted to the database, `#{inspect(__MODULE__)}` replaces a matched pattern
  with the replacement, the final replaced value will be saved into database.

      field :content, #{inspect(__MODULE__)},
        on_dump: pattern: "hello", replacement: "hi", options: [global: false]

  After a field loaded from the database, `#{inspect(__MODULE__)}` replaces a matched pattern
  with the replacement, the final replaced value will be extracted into the struct, but
  no changes into the original database.

      field :content, #{inspect(__MODULE__)},
        on_load: pattern: ~r/test/, replacement: "TEST"

  The `:pattern` and `:replacement` options are a pair required when both existed, the `:options` option
  is optional, these three options are correspond completely to `String.replace/4`.

  If no `:on_dump` or `:on_load` option(s) set, the following cases are equal from result perspective, but
  recommend to use the `:string` type as a base type:

      field :content, #{inspect(__MODULE__)}
      # equals
      field :content, :string

  Please notice that once used this type means a string replacement will be invoked in each read or write operation
  to the corresponding field, so please ensure use this type in a proper scenario to avoid a wasted performance issue,
  or find a better way to satisfy the similar use case.
  """
  use Ecto.ParameterizedType

  @doc false
  @impl true
  def type(_params), do: :string

  @doc false
  @impl true
  def init(opts) do
    opts
    |> Keyword.take([:on_dump, :on_load])
    |> Enum.reduce(%{}, fn {key, opts}, acc ->
      prepare_init(key, opts, acc)
    end)
  end

  @doc false
  @impl true
  def cast(nil, _params), do: {:ok, nil}
  def cast(data, _params) when not is_bitstring(data), do: :error

  def cast(data, _params) do
    {:ok, data}
  end

  @doc false
  @impl true
  def load(nil, _loader, _params), do: {:ok, nil}

  def load(data, _loader, %{on_load: %{pattern: pattern, replacement: replacement} = on_load}) do
    data = String.replace(data, pattern, replacement, Map.get(on_load, :options, []))
    {:ok, data}
  end

  def load(data, _loader, _params), do: {:ok, data}

  @doc false
  @impl true
  def dump(nil, _dumper, _params), do: {:ok, nil}

  def dump(data, _dumper, %{on_dump: %{pattern: pattern, replacement: replacement} = on_dump}) do
    data = String.replace(data, pattern, replacement, Map.get(on_dump, :options, []))
    {:ok, data}
  end

  def dump(data, _dumper, _params), do: {:ok, data}

  @doc false
  @impl true
  def embed_as(_format, _params) do
    # make sure `load/3` will be invoked when use `on_load` case.
    :dump
  end

  @doc false
  @impl true
  def equal?(a, b, _params), do: a == b

  defp prepare_init(_key, nil, acc), do: acc

  defp prepare_init(key, opts, acc) do
    opts
    |> Keyword.take([:pattern, :replacement, :options])
    |> Enum.into(%{})
    |> validate(key, acc)
  end

  defp validate(opts, _key, acc) when opts === %{} do
    acc
  end

  defp validate(%{pattern: pattern, replacement: replacement} = opts, key, acc)
       when pattern != nil and is_bitstring(replacement) do
    Map.put(acc, key, opts)
  end

  defp validate(opts, key, _acc) do
    raise ArgumentError, """
    #{inspect(__MODULE__)} type must both have a `:pattern` option specified as a string or a regex
    type and a `:replacement` option as a string type for `#{key}` key,
    but got `#{inspect(opts)}`, they are same to String.replace/4.

    For example:

        field :my_field, EctoTablestore.Replaceable, on_dump:
          pattern: "a,b,c", replacement: ","

    or

        field :my_field, EctoTablestore.Replaceable, on_dump:
          pattern: "a,b,c", replacement: ",", options: [global: false]
    """
  end
end