lib/bylaw/ecto/query/checks/unbounded_deletes.ex

defmodule Bylaw.Ecto.Query.Checks.UnboundedDeletes do
  @moduledoc """
  Validates that `delete_all` queries are bounded.

  This check prevents accidental table-wide deletes by requiring callers to add
  a restricting root `where` or `or_where` before `Repo.delete_all/2` runs.

  ## Examples

  Bad:

      from(Session, as: :session)

  Why this is bad:

  A `delete_all` query without a root predicate can remove every row in the
  table.

  Better:

      from(Session, as: :session)
      |> where([session: s], s.expires_at < ^DateTime.utc_now())

  Why this is better:

  The root `where` clause states the intended delete scope.

  ## Notes

  This check only requires a non-true root predicate. It does not prove the
  predicate is selective or semantically correct.

  The check only validates the root query prepared for the `:delete_all`
  operation. It requires every possible root `where` branch to include at least
  one non-true expression and does not try to prove whether that
  predicate is selective.

  ## Options

    * `:validate` - explicit `false` disables the check. Defaults to `true`.

  ## Usage

  Add this module to the explicit check list passed through `Bylaw.Ecto.Query`.
  See `Bylaw.Ecto.Query` for the full `c:Ecto.Repo.prepare_query/3` setup.
  """

  @behaviour Bylaw.Ecto.Query.Check

  alias Bylaw.Ecto.Query.Boundedness
  alias Bylaw.Ecto.Query.CheckOptions
  alias Bylaw.Ecto.Query.Issue

  @typedoc false
  @type check_opts :: list({:validate, boolean()})
  @typedoc false
  @type opts :: check_opts()

  @doc """
  Implements the `Bylaw.Ecto.Query.Check` validation callback.
  """

  @impl Bylaw.Ecto.Query.Check
  @spec validate(Bylaw.Ecto.Query.Check.operation(), Bylaw.Ecto.Query.Check.query(), opts()) ::
          Bylaw.Ecto.Query.Check.result()
  def validate(operation, query, opts) when is_list(opts) do
    check_opts = CheckOptions.normalize!(opts, [:validate])

    if CheckOptions.enabled?(check_opts) and unbounded_delete?(operation, query) do
      {:error, [issue(operation)]}
    else
      :ok
    end
  end

  def validate(_operation, _query, opts) do
    raise ArgumentError, "expected opts to be a keyword list, got: #{inspect(opts)}"
  end

  defp unbounded_delete?(:delete_all, query), do: not Boundedness.root_where_bounded?(query)
  defp unbounded_delete?(_operation, _query), do: false

  @spec issue(Bylaw.Ecto.Query.Check.operation()) :: Issue.t()
  defp issue(operation) do
    %Issue{
      check: __MODULE__,
      message: "expected delete_all query to include at least one non-true root where clause",
      meta: %{
        operation: operation
      }
    }
  end
end