diff --git a/README.md b/README.md index ee7e90b..1683a05 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ end ## API ```ex -Bind.query(schema, params) +Bind.query(params, schema) ``` Parameters: @@ -205,6 +205,25 @@ Example for descending pagination: GET /users?sort=-created_at&-start=-100 ``` +### Counting + +Set `count=true` to ask how many rows match, without paging through them. `count` is a reserved key like `sort`, `limit` and `start`. + +```ex +query = Bind.query(params, User) |> scope_to_viewer(viewer) +users = Repo.all(query) +count = if Bind.count?(params), do: Repo.one(Bind.count(query)) +``` + +`Bind.count/1` takes the query `Bind.query/3` built, with any scopes piped after it, and selects how many rows it matches: the filters, joins and cursor stay, sort, limit and preloads drop. The query is counted as a subquery, so a scope's `distinct`, `group_by` or `select` never changes the number. + +The cursor is a where on id, so with `start` or `-start` the count is the rows past it and shrinks as a client pages. Ask for the total on the first page, which has no cursor. + +``` +GET /users?count=true&limit=0 # just the number +GET /users?active[true]&count=true # first page plus the number +``` + ### Query String Support In a typical Phoenix controller, you can simply pass `conn.query_string` and get Ecto query back: diff --git a/lib/bind.ex b/lib/bind.ex index 392eee5..1536217 100644 --- a/lib/bind.ex +++ b/lib/bind.ex @@ -59,6 +59,44 @@ defmodule Bind do |> query(schema, opts) end + @doc """ + Whether the params ask for the number of matching rows: `count=true` (or `1`). + `count` is a reserved key like `sort`, `limit` and `start`. + """ + def count?(query_string) when is_binary(query_string) do + query_string + |> Bind.QueryString.to_map() + |> count?() + end + + def count?(params) when is_map(params) do + # the query-string path turns "1" into the integer 1 + Map.get(params, "count") in [true, "true", "1", 1] + end + + @doc """ + Turns a query from `query/3`, with any scopes piped after it, into one that + selects how many rows it matches: the filters, joins and cursor stay, sort, + limit and preloads drop. Run it with `Repo.one/1`. + + The cursor (`start`, `-start`) is a where on id, so with one the count is + the rows past it and shrinks as a client pages. The listing's total comes + from the first page, which has no cursor. + + The query is counted as a subquery, so a scope's `distinct`, `group_by` or + `select` never changes the number. + """ + def count(%Ecto.Query{} = query) do + rows = + query + |> exclude(:order_by) + |> exclude(:limit) + |> exclude(:offset) + |> exclude(:preload) + + from(r in subquery(rows), select: count()) + end + @doc """ Maps over query parameters, letting you transform values by pattern matching field names. """ diff --git a/mix.exs b/mix.exs index 551daf8..10f836c 100644 --- a/mix.exs +++ b/mix.exs @@ -6,7 +6,7 @@ defmodule Bind.MixProject do def project do [ app: :bind, - version: "0.13.0", + version: "0.14.0", elixir: "~> 1.14", source_url: @source_url, homepage_url: @source_url, diff --git a/test/count_test.exs b/test/count_test.exs new file mode 100644 index 0000000..e5bf932 --- /dev/null +++ b/test/count_test.exs @@ -0,0 +1,82 @@ +defmodule Bind.CountTest do + use ExUnit.Case + import Ecto.Query + + defmodule User do + use Ecto.Schema + + schema "users" do + field(:name, :string) + field(:age, :integer) + field(:team_id, :integer) + end + end + + describe "count?/1" do + test "is true for count=true and count=1" do + assert Bind.count?(%{"count" => "true"}) + assert Bind.count?(%{"count" => "1"}) + assert Bind.count?(%{"count" => true}) + assert Bind.count?("name[eq]=Alice&count=true") + assert Bind.count?("count=1") + end + + test "is false without the key or with any other value" do + refute Bind.count?(%{}) + refute Bind.count?(%{"count" => "false"}) + refute Bind.count?(%{"count" => ""}) + refute Bind.count?("name[eq]=Alice") + end + end + + describe "count/1" do + test "keeps the filters, drops sort and limit, selects the count" do + query = Bind.query("name[eq]=Alice&age[gte]=30&sort=-age&limit=5", User) + counted = inspect(Bind.count(query)) + + assert counted =~ "select: count()" + assert counted =~ "name == ^\"Alice\"" + assert counted =~ "age >= ^30" + refute counted =~ "order_by" + refute counted =~ "limit" + end + + test "keeps the cursor: it counts what the request would page through" do + query = Bind.query(%{"name[eq]" => "Alice", "-start" => 10}, User) + counted = inspect(Bind.count(query)) + + assert counted =~ "id < ^10" + end + + test "keeps scopes piped after query/3 and drops their preloads" do + query = + "age[gte]=30" + |> Bind.query(User) + |> where([u], u.team_id == 7) + |> preload(:team) + + counted = inspect(Bind.count(query)) + + assert counted =~ "team_id == 7" + assert counted =~ "select: count()" + refute counted =~ "preload" + end + + test "counts a scope's distinct rows, not the distinct of the count" do + query = "age[gte]=30" |> Bind.query(User) |> distinct(true) + counted = inspect(Bind.count(query)) + + # the distinct lives in the subquery, the count wraps it + assert counted =~ "from u0 in subquery(" + assert counted =~ "distinct: true" + assert counted =~ "select: count()" + end + + test "the count query is unlimited even without an explicit limit param" do + # query/3 limits to 10 by default; the count must not inherit that + query = Bind.query(%{}, User) + assert inspect(query) =~ "limit: 10" + refute inspect(Bind.count(query)) =~ "limit" + end + end +end