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

defmodule Bylaw.Ecto.Query.Checks.EmptyInPredicates do
  @moduledoc """
  Validates that root `where` predicates do not rely on empty `in` lists.

  This catches filters whose candidate list is already known to contain no
  non-nil values.

  ## Examples

  Bad:

      ids = []

      from(Thing, as: :thing)
      |> where([thing: t], t.id in ^ids)

  Why this is bad:

  The query can never match a row because the candidate list has no non-nil
  values. Running it still spends database work on a known-empty result.

  Better:

      if Enum.empty?(ids) do
        []
      else
        from(Thing, as: :thing)
        |> where([thing: t], t.id in ^ids)
        |> Repo.all()
      end

  Why this is better:

  The caller uses a cheap application fast path and only queries the database
  when there are candidates to match.

  ## Notes

  This check only trusts supported root `in` predicates. It intentionally leaves
  broader contradictory business logic to `ConflictingWherePredicates`.

  Such queries usually have a cheaper fast path: return `[]` before calling the
  repo. This check is separate from
  `Bylaw.Ecto.Query.Checks.ConflictingWherePredicates` because an empty list is
  usually a missing fast path rather than contradictory business logic.

  The check is intentionally narrow. It evaluates root schema fields and only
  trusts direct `in` predicates in `AND` where expressions. `Ecto.Enum` fields
  are normalized through the schema enum mapping. `or_where` and `or`
  expressions are handled as separate branches and only rejected when every
  branch contains an empty `in` predicate. Fragments, subqueries, and non-root
  bindings are ignored.

  ## 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.CheckOptions
  alias Bylaw.Ecto.Query.Introspection
  alias Bylaw.Ecto.Query.Issue
  alias Bylaw.Ecto.Query.RootWherePredicates

  @typedoc false
  @type comparable_value :: atom() | integer() | String.t()
  @typedoc false
  @type predicate :: %{
          field: atom(),
          operator: :in,
          values: list(comparable_value())
        }
  @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) do
      validate_enabled(operation, query)
    else
      :ok
    end
  end

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

  defp validate_enabled(operation, query) do
    case Introspection.root_schema(query) do
      {:ok, schema} ->
        operation
        |> issues(RootWherePredicates.branches(query, schema))
        |> result()

      :unknown ->
        :ok
    end
  end

  defp issues(operation, predicate_branches) do
    branch_empty_predicates = Enum.map(predicate_branches, &empty_in_predicates/1)

    if Enum.any?(branch_empty_predicates, &Enum.empty?/1) do
      []
    else
      branch_empty_predicates
      |> List.flatten()
      |> Enum.uniq_by(&predicate_key/1)
      |> Enum.group_by(& &1.field)
      |> Enum.map(fn {field, predicates} -> issue(operation, field, predicates) end)
      |> Enum.sort_by(& &1.meta.field)
    end
  end

  defp empty_in_predicates(predicates) do
    Enum.filter(predicates, fn predicate ->
      predicate.operator == :in and Enum.empty?(predicate.values)
    end)
  end

  defp predicate_key(predicate), do: {predicate.field, predicate.operator, predicate.values}

  defp result([]), do: :ok
  defp result(issues), do: {:error, issues}

  defp issue(operation, field, predicates) do
    %Issue{
      check: __MODULE__,
      message: "expected in predicate on #{inspect(field)} to include at least one non-nil value",
      meta: %{
        operation: operation,
        field: field,
        predicates: Enum.map(predicates, &predicate_meta/1)
      }
    }
  end

  defp predicate_meta(predicate) do
    %{
      operator: predicate.operator,
      values: predicate.values
    }
  end
end