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

defmodule Bylaw.Ecto.Query.Checks.RequiredOrder do
  @moduledoc """
  Validates that query shapes requiring stable row order include `order_by`.

  This check only answers whether an `order_by` clause is required and present.
  It intentionally does not decide whether the existing order is deterministic;
  use `Bylaw.Ecto.Query.Checks.DeterministicOrder` for that separate question.

  ## Examples

  Bad:

      from(Post, as: :post)
      |> limit(10)

  Why this is bad:

  A limited query without `order_by` asks the database for any matching 10 rows.
  Pagination, retries, and repeated calls can see rows appear, disappear, or
  move because the selected window has no stable order.

  Better:

      from(Post, as: :post)
      |> order_by([post: p], desc: p.inserted_at)
      |> limit(10)

  Why this is better:

  The selected window is taken from a declared order, so callers can reason
  about which rows are first.

  Bad:

      from(Post, as: :post)
      |> offset(50)
      |> limit(25)

  Why this is bad:

  `offset` skips an undefined set of rows when no order exists. Page boundaries
  can shift between executions.

  Better:

      from(Post, as: :post)
      |> order_by([post: p], desc: p.inserted_at)
      |> offset(50)
      |> limit(25)

  Why this is better:

  Rows are skipped from a known order, so the page boundary has a defined
  meaning.

  Bad:

      from(Post, as: :post)
      |> Repo.stream()

  Better:

      from(Post, as: :post)
      |> order_by([post: p], asc: p.id)
      |> Repo.stream()

  ## Notes

  This check only requires that some `order_by` exists. It does not prove that
  the order is deterministic. If rows can tie on the ordered field, pair this
  check with `DeterministicOrder` to require a primary-key tie-breaker:

      from(Post, as: :post)
      |> order_by([post: p], desc: p.inserted_at)
      |> order_by([post: p], asc: p.id)
      |> limit(10)

  Ecto rewrites `Repo.exists?/2` queries to `select 1` with `limit 1`. This
  synthetic limit is ignored because existence checks do not depend on which row
  is returned. A preserved `offset` still requires ordering because the skipped
  rows are otherwise undefined.

  ## Options

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

  Queries with `limit`, `offset`, or the `:stream` operation require an
  `order_by` clause. If any `order_by` exists, this check passes and leaves
  deterministic tie-breaker validation to `DeterministicOrder`.

  ## 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

  @typedoc false
  @type reason :: :limit | :offset | :stream
  @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])
    required_by = missing_order_reasons(operation, query)

    if CheckOptions.enabled?(check_opts) and not Enum.empty?(required_by) do
      {:error, [issue(operation, required_by)]}
    else
      :ok
    end
  end

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

  @spec missing_order_reasons(Bylaw.Ecto.Query.Check.operation(), term()) :: list(reason())
  defp missing_order_reasons(operation, query) do
    direct_missing_order_reasons(operation, query)
    |> Enum.concat(nested_missing_order_reasons(query))
    |> Enum.uniq()
  end

  defp direct_missing_order_reasons(operation, query) do
    required_by =
      operation
      |> required_by(query)
      |> ignore_exists_limit(operation, query)

    cond do
      Enum.empty?(required_by) -> []
      ordered?(query) -> []
      true -> required_by
    end
  end

  @spec required_by(Bylaw.Ecto.Query.Check.operation(), term()) :: list(reason())
  defp required_by(operation, query) do
    Enum.flat_map(
      [
        {:limit, limited?(query)},
        {:offset, offset?(query)},
        {:stream, operation == :stream}
      ],
      fn
        {reason, true} -> [reason]
        {_reason, false} -> []
      end
    )
  end

  defp limited?(%{limit: nil}), do: false
  defp limited?(%{limit: _limit}), do: true
  defp limited?(_query), do: false

  defp offset?(%{offset: nil}), do: false
  defp offset?(%{offset: _offset}), do: true
  defp offset?(_query), do: false

  defp ordered?(%{order_bys: order_bys}) when is_list(order_bys) do
    Enum.any?(order_bys, fn
      %{expr: exprs} when is_list(exprs) -> not Enum.empty?(exprs)
      _order_by -> false
    end)
  end

  defp ordered?(_query), do: false

  defp exists_query?(:all, query) do
    literal_select?(query, 1) and literal_limit?(query, 1)
  end

  defp exists_query?(_operation, _query), do: false

  defp ignore_exists_limit(required_by, operation, query) do
    if exists_query?(operation, query) do
      List.delete(required_by, :limit)
    else
      required_by
    end
  end

  defp literal_select?(%{select: %{expr: value}}, value), do: true
  defp literal_select?(_query, _value), do: false

  defp literal_limit?(%{limit: %{expr: value}}, value), do: true
  defp literal_limit?(_query, _value), do: false

  defp nested_missing_order_reasons(query) do
    query
    |> Introspection.nested_queries()
    |> Enum.flat_map(&missing_order_reasons(:all, &1))
  end

  @spec issue(Bylaw.Ecto.Query.Check.operation(), list(reason())) :: Issue.t()
  defp issue(operation, required_by) do
    %Issue{
      check: __MODULE__,
      message: "expected query with #{format_reasons(required_by)} to include order_by",
      meta: %{
        operation: operation,
        required_by: required_by
      }
    }
  end

  defp format_reasons([reason]), do: format_reason(reason)

  defp format_reasons(reasons) do
    Enum.map_join(reasons, ", ", &format_reason/1)
  end

  defp format_reason(:limit), do: "limit"
  defp format_reason(:offset), do: "offset"
  defp format_reason(:stream), do: "stream operation"
end