lib/bylaw/credo/check/ecto/prefer_repo_aggregate_count.ex

defmodule Bylaw.Credo.Check.Ecto.PreferRepoAggregateCount do
  @moduledoc """
  Prefer `Repo.aggregate(queryable, :count)` over loading rows with `Repo.all`
  and counting them in memory.

  ## Examples

  Avoid:

        Repo.all(query) |> Enum.count()
        Enum.count(Repo.all(query))
        query |> Repo.all() |> length()
  Prefer:

        Repo.aggregate(query, :count)

  Prefer `Repo.exists?/1` or `not Repo.exists?/1` over comparing
  `Repo.aggregate(query, :count)` to `0` or `1` for existence checks.
  Avoid:

        Repo.aggregate(query, :count) > 0
        Repo.aggregate(query, :count) == 0
  Prefer:

        Repo.exists?(query)
        not Repo.exists?(query)

  ## Notes

  This check uses static AST analysis, so it favors clear source-level patterns over runtime behavior.

  ## Options

  This check has no check-specific options. Configure it with an empty option list.

  ## Usage

  Add this check to Credo's `checks:` list in `.credo.exs`:

  ```elixir
  %{
    configs: [
      %{
        name: "default",
        checks: [
          {Bylaw.Credo.Check.Ecto.PreferRepoAggregateCount, []}
        ]
      }
    ]
  }
  ```
  """

  use Credo.Check,
    base_priority: :high,
    category: :readability,
    explanations: [
      check: @moduledoc
    ]

  @comparison_operators [:>, :>=, :<, :<=, :==, :===, :!=, :!==]
  @doc false
  @impl Credo.Check
  def run(source_file, params \\ []) do
    ctx = Context.build(source_file, params, __MODULE__)
    Credo.Code.prewalk(source_file, &walk/2, ctx).issues
  end

  defp walk({:length, meta, [value]} = ast, ctx) do
    {ast, maybe_put_issue(ctx, meta, "length", value)}
  end

  defp walk(
         {{:., meta, [{:__aliases__, _aliases_meta, [:Kernel]}, :length]}, _call_meta, [value]} =
           ast,
         ctx
       ) do
    {ast, maybe_put_issue(ctx, meta, "Kernel.length", value)}
  end

  defp walk(
         {{:., meta, [{:__aliases__, _aliases_meta, [:Enum]}, :count]}, _call_meta, [value]} = ast,
         ctx
       ) do
    {ast, maybe_put_issue(ctx, meta, "Enum.count", value)}
  end

  defp walk({:|>, _pipe_meta, [value, {:length, meta, []}]} = ast, ctx) do
    {ast, maybe_put_issue(ctx, meta, "length", value)}
  end

  defp walk(
         {:|>, _pipe_meta,
          [
            value,
            {{:., meta, [{:__aliases__, _aliases_meta, [:Kernel]}, :length]}, _call_meta, []}
          ]} = ast,
         ctx
       ) do
    {ast, maybe_put_issue(ctx, meta, "Kernel.length", value)}
  end

  defp walk(
         {:|>, _pipe_meta,
          [value, {{:., meta, [{:__aliases__, _aliases_meta, [:Enum]}, :count]}, _call_meta, []}]} =
           ast,
         ctx
       ) do
    {ast, maybe_put_issue(ctx, meta, "Enum.count", value)}
  end

  defp walk({op, meta, [left, right]} = ast, ctx) when op in @comparison_operators do
    case existence_issue(ctx, meta, op, left, right) do
      nil -> {ast, ctx}
      issue -> {ast, put_issue(ctx, issue)}
    end
  end

  defp walk(ast, ctx), do: {ast, ctx}

  defp maybe_put_issue(ctx, meta, trigger, value) do
    if repo_all_expression?(value) do
      put_issue(ctx, issue_for(ctx, meta, trigger))
    else
      ctx
    end
  end

  defp repo_all_expression?({{:., _dot_meta, [repo, :all]}, _call_meta, _args}),
    do: repo_module?(repo)

  defp repo_all_expression?({:|>, _pipe_meta, [_value, repo_all_stage]}),
    do: repo_all_stage?(repo_all_stage)

  defp repo_all_expression?(_other), do: false

  defp repo_aggregate_count_expression?(
         {{:., _dot_meta, [repo, :aggregate]}, _call_meta, arguments}
       )
       when is_list(arguments) do
    repo_module?(repo) and aggregate_count_arguments?(arguments)
  end

  defp repo_aggregate_count_expression?({:|>, _pipe_meta, [_value, aggregate_stage]}),
    do: aggregate_count_stage?(aggregate_stage)

  defp repo_aggregate_count_expression?(_other), do: false

  defp repo_all_stage?({{:., _dot_meta, [repo, :all]}, _call_meta, _args}), do: repo_module?(repo)
  defp repo_all_stage?(_other), do: false

  defp aggregate_count_stage?({{:., _dot_meta, [repo, :aggregate]}, _call_meta, arguments})
       when is_list(arguments) do
    repo_module?(repo) and aggregate_count_stage_arguments?(arguments)
  end

  defp aggregate_count_stage?(_other), do: false

  defp aggregate_count_arguments?([_queryable, :count]), do: true
  defp aggregate_count_arguments?([_queryable, :count, _field]), do: true
  defp aggregate_count_arguments?(_arguments), do: false

  defp aggregate_count_stage_arguments?([:count]), do: true
  defp aggregate_count_stage_arguments?([:count, _field]), do: true
  defp aggregate_count_stage_arguments?(_arguments), do: false

  defp repo_module?({:__aliases__, _meta, aliases}), do: List.last(aliases) == :Repo
  defp repo_module?(_other), do: false

  defp existence_issue(ctx, meta, op, left, right) do
    cond do
      repo_aggregate_count_expression?(left) and existence_comparison?(op, right) ->
        issue_for_exists(ctx, meta, Atom.to_string(op))

      repo_aggregate_count_expression?(right) and reversed_existence_comparison?(op, left) ->
        issue_for_exists(ctx, meta, Atom.to_string(op))

      true ->
        nil
    end
  end

  defp existence_comparison?(:>, 0), do: true
  defp existence_comparison?(:>=, 1), do: true
  defp existence_comparison?(:!=, 0), do: true
  defp existence_comparison?(:!==, 0), do: true
  defp existence_comparison?(:==, 0), do: true
  defp existence_comparison?(:===, 0), do: true
  defp existence_comparison?(:<, 1), do: true
  defp existence_comparison?(:<=, 0), do: true
  defp existence_comparison?(_op, _value), do: false

  defp reversed_existence_comparison?(:<, 0), do: true
  defp reversed_existence_comparison?(:<=, 1), do: true
  defp reversed_existence_comparison?(:!=, 0), do: true
  defp reversed_existence_comparison?(:!==, 0), do: true
  defp reversed_existence_comparison?(:==, 0), do: true
  defp reversed_existence_comparison?(:===, 0), do: true
  defp reversed_existence_comparison?(:>, 1), do: true
  defp reversed_existence_comparison?(:>=, 0), do: true
  defp reversed_existence_comparison?(_op, _value), do: false

  defp issue_for(ctx, meta, trigger) do
    format_issue(
      ctx,
      message:
        "Prefer `Repo.aggregate(queryable, :count)` over counting rows loaded by `Repo.all(...)` with `#{trigger}`.",
      trigger: trigger,
      line_no: meta[:line]
    )
  end

  defp issue_for_exists(ctx, meta, trigger) do
    format_issue(
      ctx,
      message:
        "Prefer `Repo.exists?/1` or `not Repo.exists?/1` over count-based existence checks with `Repo.aggregate(..., :count)`.",
      trigger: trigger,
      line_no: meta[:line]
    )
  end
end