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

defmodule Bylaw.Credo.Check.Ecto.NoAndInWhere do
  @moduledoc """
  Split combined Ecto `where` predicates into separate `where` clauses.

  ## Examples

  Avoid:

        User
        |> where([u], u.active and u.confirmed_at > ^cutoff)
        |> Repo.all()

  Prefer:

        User
        |> where([u], u.active)
        |> where([u], u.confirmed_at > ^cutoff)
        |> Repo.all()

  ## Notes

  Packing multiple predicates into one `where` expression makes query
  composition harder. Separate clauses are easier to add, remove, reorder,
  and conditionally compose with helper functions.

  Each clause carries one constraint, which keeps incremental query
  building clear and makes diffs smaller when a predicate changes.

  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.NoAndInWhere, []}
        ]
      }
    ]
  }
  ```
  """

  use Credo.Check,
    base_priority: :higher,
    category: :warning,
    explanations: [
      check: @moduledoc
    ]

  @doc false
  @impl Credo.Check
  def run(%Credo.SourceFile{} = source_file, params \\ []) do
    issue_meta = IssueMeta.for(source_file, params)
    Credo.Code.prewalk(source_file, &traverse(&1, &2, issue_meta))
  end

  defp traverse({:where, meta, arguments} = ast, issues, issue_meta) do
    issues =
      case where_expression(arguments) do
        nil ->
          issues

        expression ->
          if contains_and?(expression) do
            [issue_for(issue_meta, meta[:line] || 0) | issues]
          else
            issues
          end
      end

    {ast, issues}
  end

  defp traverse({:from, meta, arguments} = ast, issues, issue_meta) do
    issues =
      case from_where_expression(arguments) do
        nil ->
          issues

        expression ->
          if contains_and?(expression) do
            [issue_for(issue_meta, meta[:line] || 0) | issues]
          else
            issues
          end
      end

    {ast, issues}
  end

  defp traverse(ast, issues, _issue_meta), do: {ast, issues}

  defp where_expression([_bindings, expression]), do: expression
  defp where_expression([expression]), do: expression
  defp where_expression(_arguments), do: nil

  defp from_where_expression([_queryable, options]) when is_list(options) do
    Keyword.get(options, :where)
  end

  defp from_where_expression(_arguments), do: nil

  defp contains_and?(ast) do
    ast
    |> Macro.prewalk(false, fn
      {:and, _meta, [_left, _right]} = node, _acc ->
        {node, true}

      node, acc ->
        {node, acc}
    end)
    |> elem(1)
  end

  defp issue_for(issue_meta, line_no) do
    format_issue(
      issue_meta,
      message:
        "Do not use `and` inside Ecto `where` clauses. Prefer each clause to be its own `where`.",
      trigger: "and",
      line_no: line_no
    )
  end
end