Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ end
## API

```ex
Bind.query(schema, params)
Bind.query(params, schema)
```

Parameters:
Expand Down Expand Up @@ -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:
Expand Down
38 changes: 38 additions & 0 deletions lib/bind.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The count keeps the pagination cursor, so a paginating client gets a shrinking total.
start is added as a WHERE on id, so exclude(:offset) drops nothing here.
On page 2+ the number is only the rows after the cursor.
If the goal is a listing size, count the filters without the cursor.

|> exclude(:preload)

from(r in subquery(rows), select: count())
end

@doc """
Maps over query parameters, letting you transform values by pattern matching field names.
"""
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
82 changes: 82 additions & 0 deletions test/count_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading