Skip to content
Draft
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
23 changes: 23 additions & 0 deletions guides/fields/computed-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,29 @@ the `index` and `show` views only.
> `Backpex.Adapters.Ecto` merges the values into the selected items, so add a virtual field with the name of each
> value to your schema, e.g. `field :full_name, :string, virtual: true`.

## Aggregates of related items

`Backpex.Expr.aggregate/3` computes a value of the related items of a relationship to many items, e.g. the number of
the posts of a user, the sum of their likes or the title of the post with the most likes:

```elixir
@impl Backpex.LiveResource
def computed(_assigns) do
[
post_count: aggregate(:count, :posts),
published_count: aggregate(:count, :posts, where: expr(published == true)),
total_likes: aggregate(:sum, :posts, value: expr(likes)),
top_post: aggregate(:first, :posts, value: expr(title), sort: [likes: :desc]),
last_commenter: aggregate(:first, [:posts, :comments], value: expr(author.name), sort: [inserted_at: :desc])
]
end
```

The path can go through several relationships, e.g. `[:posts, :tags]`, and the value through relationships to one item,
e.g. `expr(author.name)`. `Backpex.Adapters.Ecto` joins a subquery of the values by item, so an aggregate doesn't
duplicate items. Aggregates that only differ in their `:value`, e.g. the title and the likes of the top post, share
one subquery. The index view sorts by aggregates and searches them like any other computed value.

## Values of the database

A value the expressions can't describe, e.g. a fragment of SQL, is a native expression of the adapter (see
Expand Down
1 change: 1 addition & 0 deletions guides/upgrading/v0.25.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ LiveResource that return expressions (see `Backpex.Expr`) and work with every ad
| `select_merge` in the `item_query`, the `:select` of a field | `c:Backpex.LiveResource.computed/1`, e.g. `full_name: expr(concat([first_name, last_name], " "))` |
| `:select` of an association field and `select_merge` in its `:options_query` | `:computed` of the query of the relationship in `load/1`, which the options get, too |
| `order_by` on a join in the `item_query` | `init_order: %{by: :"author.last_name", direction: :asc}` |
| a join of a subquery of related items, e.g. their number or the value of the newest one | `Backpex.Expr.aggregate/3` in `computed/1`, e.g. `aggregate(:first, :comments, value: expr(author.name), sort: [inserted_at: :desc])` |
| joins for a `:select` or under a `:custom_alias` | paths in expressions, e.g. `expr(author.last_name)` |
| a SQL fragment | `Backpex.Expr.native/3`, with `uses: [:author]` for the joins of paths |

Expand Down
60 changes: 60 additions & 0 deletions integration_test/options/computed_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
defmodule Backpex.Integration.Options.ComputedTest do
@moduledoc """
Checks aggregates over relationships to many items as computed values, through `Backpex.Test.UserStatsLive`.
"""
use Backpex.ConnCase, async: true

setup do
ann = insert(:user, username: "ann", posts: [])
bob = insert(:user, username: "bob", posts: [])

insert(:post, user: ann, title: "Small", likes: 1, published: false, tags: [build(:tag, name: "zig")])
insert(:post, user: ann, title: "Big", likes: 9, published: true, tags: [build(:tag, name: "elixir")])

%{ann: ann, bob: bob}
end

test "shows the aggregates of the related items, and nil or 0 without related items", %{conn: conn} do
conn
|> visit(~p"/admin/user-stats")
|> assert_rows("table tbody", ["ann", "bob"])
|> assert_has("tbody tr:nth-child(1) td", text: "Big", exact: true)
|> assert_has("tbody tr:nth-child(1) td", text: "elixir", exact: true)
|> assert_has("tbody tr:nth-child(1) td", text: "10", exact: true)
|> assert_has("tbody tr:nth-child(1) td", text: "2", exact: true)
|> assert_has("tbody tr:nth-child(1) td", text: "1", exact: true)
|> assert_has("tbody tr:nth-child(2) td", text: "0", exact: true)
end

test "reads aggregates that share a join and computes with aggregates", %{ann: ann, bob: bob} do
{:ok, users} =
[]
|> Backpex.Resource.items_query(%{}, Backpex.Test.UserStatsLive)
|> Backpex.Query.sort(username: :asc)
|> Backpex.Resource.read()

assert [
%{id: ann_id, top_post: "Big", top_post_likes: 9, draft_count: 1},
%{id: bob_id, top_post: nil, top_post_likes: nil, draft_count: 0}
] = users

assert {ann_id, bob_id} == {ann.id, bob.id}
end

test "sorts by an aggregate and searches it", %{conn: conn} do
conn
|> visit(~p"/admin/user-stats?#{%{order_by: "post_count", order_direction: "asc"}}")
|> assert_rows("table tbody", ["bob", "ann"])

conn
|> visit(~p"/admin/user-stats?#{%{search: "bi"}}")
|> assert_rows("table tbody", ["ann"])
end

test "reads the aggregates of a single item", %{conn: conn, ann: ann} do
conn
|> visit(~p"/admin/user-stats/#{ann.id}/show")
|> assert_has("dd", text: "Big")
|> assert_has("dd", text: "elixir")
end
end
53 changes: 53 additions & 0 deletions integration_test/support/app/live/user_stats_live.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
defmodule Backpex.Test.UserStatsLive do
@moduledoc """
Users with computed values of their posts: aggregates over a relationship to many items, with a `:where`, a `:value`
and a `:sort`, over the path `posts.tags`, aggregates that share a join and arithmetic of aggregates.
"""
use Backpex.LiveResource,
adapter_config: [
schema: Backpex.Test.User,
repo: Backpex.Test.Repo,
update_changeset: &Backpex.Test.User.changeset/3,
create_changeset: &Backpex.Test.User.changeset/3
],
init_order: %{by: :username, direction: :asc}

import Backpex.Expr

@impl Backpex.LiveResource
def layout(_assigns), do: {Backpex.Test.Layouts, :admin}

@impl Backpex.LiveResource
def singular_name, do: "User"

@impl Backpex.LiveResource
def plural_name, do: "Users"

@impl Backpex.LiveResource
def computed(_assigns) do
[
post_count: aggregate(:count, :posts),
published_count: aggregate(:count, :posts, where: expr(published == true)),
total_likes: aggregate(:sum, :posts, value: expr(likes)),
top_post: aggregate(:first, :posts, value: expr(title), sort: [likes: :desc]),
# Shares the join of `top_post`, as it only differs in the value.
top_post_likes: aggregate(:first, :posts, value: expr(likes), sort: [likes: :desc]),
draft_count: expr(^aggregate(:count, :posts) - ^aggregate(:count, :posts, where: expr(published == true))),
first_tag: aggregate(:first, [:posts, :tags], value: expr(name), sort: [name: :asc])
]
end

@impl Backpex.LiveResource
def fields do
[
username: %{module: Backpex.Fields.Text, label: "Username"},
post_count: %{module: Backpex.Fields.Number, label: "Posts"},
published_count: %{module: Backpex.Fields.Number, label: "Published"},
total_likes: %{module: Backpex.Fields.Number, label: "Likes"},
top_post: %{module: Backpex.Fields.Text, label: "Top post", searchable: true},
top_post_likes: %{module: Backpex.Fields.Number, label: "Top post likes"},
draft_count: %{module: Backpex.Fields.Number, label: "Drafts"},
first_tag: %{module: Backpex.Fields.Text, label: "First tag"}
]
end
end
1 change: 1 addition & 0 deletions integration_test/support/app/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ defmodule Backpex.Test.Router do
live_resources "/scoped-posts", Backpex.Test.ScopedPostLive
live_resources "/memory-notes", Backpex.Test.MemoryNoteLive
live_resources "/read-only-notes", Backpex.Test.ReadOnlyNoteLive
live_resources "/user-stats", Backpex.Test.UserStatsLive
live_resources "/user-relation-options", Backpex.Test.UserRelationOptionsLive
live_resources "/product-short-links", Backpex.Test.ProductShortLinksLive, except: [:new]
live_resources "/sample-field-options", Backpex.Test.SampleFieldOptionsLive
Expand Down
8 changes: 8 additions & 0 deletions integration_test/support/app/schemas/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ defmodule Backpex.Test.User do
field :first_name, :string
field :last_name, :string
field :full_name, :string, virtual: true
# The computed values of `Backpex.Test.UserStatsLive`.
field :post_count, :integer, virtual: true
field :published_count, :integer, virtual: true
field :total_likes, :integer, virtual: true
field :top_post, :string, virtual: true
field :top_post_likes, :integer, virtual: true
field :draft_count, :integer, virtual: true
field :first_tag, :string, virtual: true
field :age, :integer
field :role, Ecto.Enum, values: [:user, :admin]
field :avatar, :string
Expand Down
22 changes: 20 additions & 2 deletions lib/backpex/adapters/ecto.ex
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ defmodule Backpex.Adapters.Ecto do
import Ecto.Query

alias Backpex.Adapters.Ecto.Where
alias Backpex.Expr.Aggregate
alias Backpex.Expr.Call
alias Backpex.Expr.Native
alias Backpex.Expr.Ref
Expand Down Expand Up @@ -184,7 +185,7 @@ defmodule Backpex.Adapters.Ecto do
do: aggregate_dynamic(ecto_query, {kind, Backpex.Expr.ref(field)})

defp aggregate_dynamic(ecto_query, {kind, %struct{} = expression})
when kind in [:count, :sum, :avg, :min, :max] and struct in [Ref, Call] do
when kind in [:count, :sum, :avg, :min, :max] and struct in [Ref, Call, Aggregate] do
{ecto_query, value} = Where.value(ecto_query, expression)

case kind do
Expand Down Expand Up @@ -518,7 +519,9 @@ defmodule Backpex.Adapters.Ecto do

defp merge_computed(ecto_query, %Query{computed: computed}) when computed == %{}, do: ecto_query

defp merge_computed(ecto_query, %Query{computed: computed}) do
defp merge_computed(ecto_query, %Query{computed: computed} = query) do
ecto_query = Where.join_aggregates(ecto_query, aggregate_expressions(query))

{values, ecto_query} =
Enum.map_reduce(computed, ecto_query, fn {name, expression}, ecto_query ->
{ecto_query, value} = Where.value(ecto_query, expression)
Expand All @@ -528,6 +531,21 @@ defmodule Backpex.Adapters.Ecto do
select_merge(ecto_query, ^Map.new(values))
end

# The expressions whose aggregates share joins, see `Backpex.Adapters.Ecto.Where.join_aggregates/2`: the computed
# values, the ones of related items that sorts and the search refer to, and the conditions of the search.
defp aggregate_expressions(%Query{computed: computed, search: search} = query) do
sort_targets = query |> Query.sorts() |> Enum.map(fn {ref, _direction} -> ref end)
search_targets = if search, do: search.targets, else: []

related =
Enum.flat_map(sort_targets ++ search_targets, fn
%Ref{} = ref -> query |> Query.computed_expr(ref) |> List.wrap()
condition -> [condition]
end)

Map.values(computed) ++ related
end

# --- QUERIES

@doc """
Expand Down
Loading
Loading