lib/ecto_anon.ex

defmodule EctoAnon do
  @moduledoc """
  `EctoAnon` provides a simple way to handle data anonymization directly in your Ecto schemas.
  """

  @doc """
  Updates an Ecto struct with anonymized data based on its anon_schema declaration.
  It also updates any embedded schema declared in anon_schema.

      defmodule User do
        use Ecto.Schema
        use EctoAnon.Schema

        anon_schema [
          :firstname,
          email: &__MODULE__.anonymized_email/3
          birthdate: [:anonymized_date, options: [:only_year]]
        ]

        schema "user" do
          field :fistname, :string
          field :email, :string
          field :birthdate, :utc_datetime
        end

        def anonymized_email(_type, _value, _opts) do
          "xxx@xxx.com"
        end
      end

  It returns `{:ok, struct}` if the struct has been successfully updated or `{:error, :non_anonymizable_struct}` if the struct has no anonymizable fields.

  ## Options

    * `:cascade` - When set to `true`, allows ecto_anon to preload and anonymize
    all associations (and associations of these associations) automatically in cascade.
    Could be used to anonymize all data related a struct in a single call.
    Note that this won't traverse `belongs_to` associations to avoid infinite and cyclic anonymizations.
    Default: false

    * `:log`- When set to `true`, it will set `anonymized` field when EctoAnon.run
    applies anonymization on a ressource.
    Default: true

  ## Example

      defmodule User do
        use Ecto.Schema
        use EctoAnon.Schema

        anon_schema [
          :email
        ]

        schema "users" do
          field :name, :string
          field :age, :integer, default: 0
          field :email, :string
        end
      end

  By default, an anon_field will be anonymized with a default value based on its type:

      iex> user = Repo.get(User, id)
      %User{name: "jane", age: 0, email: "jane@email.com"}

      iex> EctoAnon.run(user, Repo)
      {:ok, %User{name: "jane", age: 0, email: "redacted"}}

  """
  @spec run(struct(), Ecto.Repo.t(), keyword()) ::
          {:ok, Ecto.Schema.t()} | {:error, :non_anonymizable_struct}

  def run(struct, repo, _opts \\ [])

  def run(struct, _repo, _opts) when struct in [[], nil], do: {:error, :non_anonymizable_struct}
  def run(struct, repo, opts) when is_list(struct), do: Enum.each(struct, &run(&1, repo, opts))

  def run(%mod{} = struct, repo, cascade: true) do
    anon_fields = mod.__anon_fields__() |> Enum.map(fn {field, _} -> field end)

    associations =
      mod.__schema__(:associations)
      |> Enum.filter(&(&1 in anon_fields and EctoAnon.Anonymizer.is_association?(mod, &1)))

    struct = repo.preload(struct, associations)

    associations
    |> Enum.each(&run(Map.get(struct, &1), repo, cascade: true))

    run(struct, repo)
  end

  def run(struct, repo, opts) do
    with {:ok, data} <- EctoAnon.Anonymizer.anonymized_data(struct),
         {:ok, anonymized_data} <- EctoAnon.Query.apply(data, repo, struct) do
      if set_anonymized?(struct, opts) do
        EctoAnon.Query.set_anonymized(repo, anonymized_data)
      else
        {:ok, anonymized_data}
      end
    else
      {:error, error} -> {:error, error}
    end
  end

  defp set_anonymized?(%mod{} = _struct, opts) do
    Keyword.get(opts, :log, true) and :anonymized in mod.__schema__(:fields)
  end
end