lib/ecto/soft_delete_repo.ex

defmodule Ecto.SoftDelete.Repo do
  @moduledoc """
  Adds soft delete functions to an repository.

      defmodule Repo do
        use Ecto.Repo,
          otp_app: :my_app,
          adapter: Ecto.Adapters.Postgres
        use Ecto.SoftDelete.Repo
      end

  """

  @doc """
  Soft deletes all entries matching the given query.

  It returns a tuple containing the number of entries and any returned
  result as second element. The second element is `nil` by default
  unless a `select` is supplied in the update query.

  ## Options

  All options supported by `c:Ecto.Repo.update_all/3` can be used.

  ## Examples

      MyRepo.soft_delete_all(Post)
      from(p in Post, where: p.id < 10) |> MyRepo.soft_delete_all()

      # With schema prefix for multi-tenant databases
      MyRepo.soft_delete_all(Post, prefix: "tenant_abc")

  """
  @callback soft_delete_all(queryable :: Ecto.Queryable.t(), opts :: Keyword.t()) ::
              {integer, nil | [term()]}

  @doc """
  Soft deletes a struct.
  Updates the `deleted_at` field with the current datetime in UTC.
  It returns `{:ok, struct}` if the struct has been successfully
  soft deleted or `{:error, changeset}` if there was a validation
  or a known constraint error.

  ## Options

  All options supported by `c:Ecto.Repo.update/2` can be used.

  ## Examples

      post = MyRepo.get!(Post, 42)
      case MyRepo.soft_delete post do
        {:ok, struct}       -> # Soft deleted with success
        {:error, changeset} -> # Something went wrong
      end

      # With schema prefix for multi-tenant databases
      MyRepo.soft_delete(post, prefix: "tenant_abc")

  """
  @callback soft_delete(
              struct_or_changeset :: Ecto.Schema.t() | Ecto.Changeset.t(),
              opts :: Keyword.t()
            ) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}

  @doc """
  Same as `c:soft_delete/2` but returns the struct or raises if the changeset is invalid.
  """
  @callback soft_delete!(
              struct_or_changeset :: Ecto.Schema.t() | Ecto.Changeset.t(),
              opts :: Keyword.t()
            ) :: Ecto.Schema.t()

  defmacro __using__(_opts) do
    quote do
      import Ecto.Query
      import Ecto.SoftDelete.Query

      def soft_delete_all(queryable, opts \\ []) do
        set_expr = [set: [deleted_at: DateTime.utc_now()]]
        update_all(queryable, set_expr, opts)
      end

      # Define soft_delete and soft_delete! with options
      def soft_delete(struct_or_changeset, opts \\ []) do
        struct_or_changeset
        |> Ecto.Changeset.change(deleted_at: DateTime.utc_now())
        |> __MODULE__.update(opts)
      end

      def soft_delete!(struct_or_changeset, opts \\ []) do
        struct_or_changeset
        |> Ecto.Changeset.change(deleted_at: DateTime.utc_now())
        |> __MODULE__.update!(opts)
      end

      @doc """
      Overrides all query operations to exclude soft deleted records
      if the schema in the from clause has a deleted_at column
      NOTE: will not exclude soft deleted records if :with_deleted option passed as true
      """
      def prepare_query(_operation, query, opts) do
        skip_deleted_at_clause? =
          has_include_deleted_at_clause?(query) ||
            opts[:with_deleted] ||
            !soft_deletable?(query) ||
            !auto_include_deleted_at_clause?(query)

        if skip_deleted_at_clause? do
          {query, opts}
        else
          query = from(x in query, where: is_nil(x.deleted_at))
          {query, opts}
        end
      end

      # Checks the query to see if it contains a where not is_nil(deleted_at)
      # if it does, we want to be sure that we don't exclude soft deleted records
      defp has_include_deleted_at_clause?(%Ecto.Query{wheres: wheres}) do
        Enum.any?(wheres, fn %{expr: expr} ->
          expr
          |> Inspect.Algebra.to_doc(%Inspect.Opts{
            inspect_fun: fn expr, _ ->
              inspect(expr, limit: :infinity)
            end
          })
          |> String.contains?(
            "{:not, [], [{:is_nil, [], [{{:., [], [{:&, [], [0]}, :deleted_at]}, [], []}]}]}"
          )
        end)
      end
    end
  end
end