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