defmodule Bylaw.Ecto.Query.Checks.OffsetWithoutLimit do
@moduledoc """
Validates that queries do not use `offset` without `limit`.
Offset without limit skips rows and then returns every remaining row. That can
create an unbounded scan from an arbitrary position, which is usually an
accidental pagination shape.
## Examples
Bad:
from(Post, as: :post)
|> order_by([post: p], asc: p.inserted_at)
|> offset(10_000)
Why this is bad:
The query skips 10,000 rows and then returns every remaining row. That is
usually an accidental unbounded pagination query.
Better:
from(Post, as: :post)
|> order_by([post: p], asc: p.inserted_at)
|> limit(50)
|> offset(10_000)
Why this is better:
`limit` gives the page a bounded size. Pair this with
`Bylaw.Ecto.Query.Checks.RequiredOrder` when the page also needs stable row
order.
## Notes
This check only verifies that `offset` has a paired `limit`. It does not prove
the order is deterministic or that offset pagination is the best strategy for
a large table.
## Options
* `:validate` - explicit `false` disables the check. Defaults to `true`.
The check applies to the root query and nested source subqueries, join
subqueries, CTE queries, combination branches, and expression subqueries.
## 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 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 offset_without_limit?(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 offset_without_limit?(query) when is_map(query) do
direct_offset_without_limit?(query) or
query
|> Introspection.nested_queries()
|> Enum.any?(&offset_without_limit?/1)
end
defp offset_without_limit?(_query), do: false
defp direct_offset_without_limit?(query), do: offset?(query) and not limited?(query)
defp offset?(%{offset: nil}), do: false
defp offset?(%{offset: offset}), do: expression_present?(offset)
defp offset?(_query), do: false
defp limited?(%{limit: nil}), do: false
defp limited?(%{limit: limit}), do: expression_present?(limit)
defp limited?(_query), do: false
defp expression_present?(%{expr: expr, params: params}) when is_list(params) do
expression_present?(expr, params)
end
defp expression_present?(%{expr: expr}) do
expression_present?(expr, [])
end
defp expression_present?(_expr), do: true
defp expression_present?(nil, _params), do: false
defp expression_present?({:^, _meta, [index]}, params) when is_integer(index) do
case Enum.fetch(params, index) do
{:ok, param} -> not nil_param?(param)
:error -> true
end
end
defp expression_present?({:type, _meta, [expr, _type]}, params) do
expression_present?(expr, params)
end
defp expression_present?(_expr, _params), do: true
defp nil_param?({nil, _type}), do: true
defp nil_param?(nil), do: true
defp nil_param?(_param), do: false
@spec issue(Bylaw.Ecto.Query.Check.operation()) :: Issue.t()
defp issue(operation) do
%Issue{
check: __MODULE__,
message: "expected query with offset to include limit",
meta: %{
operation: operation,
reason: :offset_without_limit
}
}
end
end