feat: support :first aggregate (#153)

feat: support more sort orders
This commit is contained in:
Zach Daniel 2020-12-28 19:18:01 -05:00 committed by GitHub
parent 949bae3922
commit d600c55509
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 117 additions and 122 deletions

View file

@ -32,8 +32,11 @@ locals_without_parens = [
destroy: 2,
event: 1,
expensive?: 1,
field: 1,
field_type: 1,
filter: 1,
first: 3,
first: 4,
generated?: 1,
global?: 1,
has_many: 2,
@ -67,6 +70,7 @@ locals_without_parens = [
resource: 1,
resource: 2,
soft?: 1,
sort: 1,
source_field: 1,
source_field_on_join_table: 1,
strategy: 1,

View file

@ -27,7 +27,9 @@ defmodule Ash do
@type resource :: module
@type side_loads :: term
@type page :: Ash.Page.Keyset.t() | Ash.Page.Offset.t()
@type sort :: list(atom | {atom, :asc} | {atom, :desc})
@type sort_order ::
:asc | :desc | :asc_nils_first | :asc_nils_last | :desc_nils_first | :desc_nils_last
@type sort :: list(atom | {atom, sort_order})
@type validation :: Ash.Resource.Validation.t()
@type notification :: Ash.Notifier.Notification.t()

View file

@ -593,7 +593,7 @@ defmodule Ash.Actions.Read do
},
query
) do
case Ash.Query.Aggregate.new(destination_resource, :count, :count, [], nil) do
case Ash.Query.Aggregate.new(destination_resource, :count, :count, [], nil, nil) do
{:ok, aggregate} ->
Ash.DataLayer.run_aggregate_query_with_lateral_join(
query,
@ -611,7 +611,7 @@ defmodule Ash.Actions.Read do
end
defp run_count_query(ash_query, query) do
case Ash.Query.Aggregate.new(ash_query.resource, :count, :count, [], nil) do
case Ash.Query.Aggregate.new(ash_query.resource, :count, :count, [], nil, nil) do
{:ok, aggregate} ->
Ash.DataLayer.run_aggregate_query(query, [aggregate], ash_query.resource)

View file

@ -7,12 +7,14 @@ defmodule Ash.Actions.Sort do
UnsortableAttribute
}
@sort_orders [:asc, :desc, :asc_nils_first, :asc_nils_last, :desc_nils_first, :desc_nils_last]
def process(_resource, empty, _aggregates) when empty in [nil, []], do: {:ok, []}
def process(resource, sort, aggregates) when is_list(sort) do
sort
|> Enum.reduce({[], []}, fn
{field, order}, {sorts, errors} when order in [:asc, :desc] ->
{field, order}, {sorts, errors} when order in @sort_orders ->
attribute = Ash.Resource.attribute(resource, field)
cond do
@ -57,18 +59,6 @@ defmodule Ash.Actions.Sort do
end)
end
def reverse(sort) do
Enum.map(sort, fn {field, direction} ->
case direction do
:asc ->
{field, :desc}
:desc ->
{field, :asc}
end
end)
end
defp aggregate_sort(aggregates, field, order, resource, sorts, errors) do
aggregate = Map.get(aggregates, field)
@ -115,6 +105,46 @@ defmodule Ash.Actions.Sort do
defp to_sort_by_fun(:desc),
do: &(elem(&1, 1) >= elem(&2, 1))
defp to_sort_by_fun(:asc_nils_last) do
fn x, y ->
if is_nil(elem(x, 1)) && !is_nil(elem(y, 1)) do
false
else
elem(x, 1) <= elem(y, 1)
end
end
end
defp to_sort_by_fun(:asc_nils_first) do
fn x, y ->
if is_nil(elem(x, 1)) && !is_nil(elem(y, 1)) do
true
else
elem(x, 1) <= elem(y, 1)
end
end
end
defp to_sort_by_fun(:desc_nulls_first) do
fn x, y ->
if is_nil(elem(x, 1)) && !is_nil(elem(y, 1)) do
true
else
elem(x, 1) >= elem(y, 1)
end
end
end
defp to_sort_by_fun(:desc_nulls_last) do
fn x, y ->
if is_nil(elem(x, 1)) && !is_nil(elem(y, 1)) do
false
else
elem(x, 1) >= elem(y, 1)
end
end
end
defp to_sort_by_fun(module) when is_atom(module),
do: &(module.compare(elem(&1, 1), elem(&2, 1)) != :gt)

View file

@ -547,46 +547,6 @@ defmodule Ash.Api do
read(api, query, Keyword.put(opts, :page, page_opts))
end
def page(api, %Ash.Page.Keyset{rerun: {query, opts}}, :last) do
query_reverse_sorted =
case query.sort do
nil ->
sort =
query.resource
|> Ash.Resource.primary_key()
|> Enum.map(&{&1, :desc})
Ash.Query.sort(query, sort)
sort ->
new_sorted =
query
|> Ash.Query.unset(:sort)
|> Ash.Query.sort(Ash.Actions.Sort.reverse(sort))
if Ash.Actions.Sort.sorting_on_identity?(new_sorted) do
new_sorted
else
sort =
query.resource
|> Ash.Resource.primary_key()
|> Enum.map(&{&1, :desc})
Ash.Query.sort(new_sorted, sort)
end
end
new_page_params = Keyword.drop(opts[:page] || [], [:before, :after])
case read(api, query_reverse_sorted, Keyword.put(opts, :page, new_page_params)) do
{:ok, page} ->
{:ok, Map.update!(page, :results, &Enum.reverse/1)}
{:error, error} ->
{:error, error}
end
end
def page(
api,
%Ash.Page.Offset{count: count, limit: limit, offset: offset, rerun: {query, opts}},
@ -622,17 +582,7 @@ defmodule Ash.Api do
end
if request == :last && !count do
case read(
api,
Ash.Query.reverse(query),
Keyword.put(opts, :page, page_opts)
) do
{:ok, page} ->
{:ok, Map.update!(page, :results, &Enum.reverse/1)}
{:error, error} ->
{:error, error}
end
{:error, "Cannot fetch last page without counting"}
else
read(api, query, Keyword.put(opts, :page, page_opts))
end

View file

@ -85,9 +85,17 @@ defmodule Ash.Page.Keyset do
end
defp operator(:after, :asc), do: :gt
defp operator(:after, :asc_nils_first), do: :gt
defp operator(:after, :asc_nils_last), do: :gt
defp operator(:after, :desc), do: :lt
defp operator(:after, :desc_nulls_first), do: :lt
defp operator(:after, :desc_nulls_last), do: :lt
defp operator(:before, :asc), do: :lt
defp operator(:before, :asc_nils_first), do: :lt
defp operator(:before, :asc_nils_last), do: :lt
defp operator(:before, :desc), do: :gt
defp operator(:before, :desc_nulls_first), do: :gt
defp operator(:before, :desc_nulls_last), do: :gt
defp zip_fields(pkey, values, acc \\ [])
defp zip_fields([], [], acc), do: {:ok, Enum.reverse(acc)}

View file

@ -6,13 +6,14 @@ defmodule Ash.Query.Aggregate do
:default_value,
:resource,
:query,
:field,
:kind,
:type,
:authorization_filter,
:load
]
@kinds [:count]
@kinds [:count, :first]
@type t :: %__MODULE__{}
@type kind :: unquote(Enum.reduce(@kinds, &{:|, [], [&1, &2]}))
@ -25,8 +26,14 @@ defmodule Ash.Query.Aggregate do
alias Ash.Actions.SideLoad
alias Ash.Engine.Request
def new(resource, name, kind, relationship, query) do
with {:ok, type} <- kind_to_type(kind),
def new(resource, name, kind, relationship, query, field) do
field_type =
if field do
related = Ash.Resource.related(resource, relationship)
Ash.Resource.attribute(related, field).type
end
with {:ok, type} <- kind_to_type(kind, field_type),
{:ok, query} <- validate_query(query) do
{:ok,
%__MODULE__{
@ -34,6 +41,7 @@ defmodule Ash.Query.Aggregate do
resource: resource,
default_value: default_value(kind),
relationship_path: List.wrap(relationship),
field: field,
kind: kind,
type: type,
query: query
@ -42,6 +50,7 @@ defmodule Ash.Query.Aggregate do
end
defp default_value(:count), do: 0
defp default_value(:first), do: nil
defp validate_query(nil), do: {:ok, nil}
@ -53,9 +62,6 @@ defmodule Ash.Query.Aggregate do
query.aggregates != %{} ->
{:error, "Cannot aggregate in an aggregate"}
query.sort != [] ->
{:error, "Cannot sort an aggregate (for now)"}
not is_nil(query.limit) ->
{:error, "Cannot limit an aggregate (for now)"}
@ -68,8 +74,10 @@ defmodule Ash.Query.Aggregate do
end
@doc false
def kind_to_type(:count), do: {:ok, Ash.Type.Integer}
def kind_to_type(kind), do: {:error, "Invalid aggregate kind: #{kind}"}
def kind_to_type(:count, _field_type), do: {:ok, Ash.Type.Integer}
def kind_to_type(:first, nil), do: {:error, "Must provide field type for :first"}
def kind_to_type(:first, field_type), do: {:ok, field_type}
def kind_to_type(kind, _field_type), do: {:error, "Invalid aggregate kind: #{kind}"}
def requests(initial_query, can_be_in_query?, authorizing?) do
initial_query.aggregates

View file

@ -302,14 +302,15 @@ defmodule Ash.Query do
related = Ash.Resource.related(query.resource, aggregate.relationship_path)
with %{valid?: true} = aggregate_query <-
build(related, filter: aggregate.filter),
build(related, filter: aggregate.filter, sort: aggregate.sort),
{:ok, query_aggregate} <-
Aggregate.new(
query.resource,
aggregate.name,
aggregate.kind,
aggregate.relationship_path,
aggregate_query
aggregate_query,
aggregate.field
) do
query_aggregate = %{query_aggregate | load: field}
new_aggregates = Map.put(query.aggregates, aggregate.name, query_aggregate)
@ -555,7 +556,7 @@ defmodule Ash.Query do
atom | list(atom),
Ash.query() | nil
) :: t()
def aggregate(query, name, type, relationship, agg_query \\ nil) do
def aggregate(query, name, type, relationship, field \\ nil, agg_query \\ nil) do
query = to_query(query)
relationship = List.wrap(relationship)
@ -572,7 +573,7 @@ defmodule Ash.Query do
build(Ash.Resource.related(query.resource, relationship), options)
end
case Aggregate.new(query.resource, name, type, relationship, agg_query) do
case Aggregate.new(query.resource, name, type, relationship, agg_query, field) do
{:ok, aggregate} ->
new_aggregates = Map.put(query.aggregates, aggregate.name, aggregate)
@ -814,22 +815,6 @@ defmodule Ash.Query do
end
end
@doc """
Reverse the sort order of a query.
If the query has no sort, an error is added indicating that.
"""
@spec reverse(t()) :: t()
def reverse(%{sort: nil} = query) do
add_error(query, :sort, "Unreversable sort")
end
def reverse(query) do
query
|> Ash.Query.unset(:sort)
|> Ash.Query.sort(Ash.Actions.Sort.reverse(query.sort))
end
@spec unset(Ash.resource() | t(), atom | [atom]) :: t()
def unset(query, keys) when is_list(keys) do
query = to_query(query)

View file

@ -1,6 +1,6 @@
defmodule Ash.Resource.Aggregate do
@moduledoc "Represents a named aggregate on the resource that can be loaded"
defstruct [:name, :relationship_path, :filter, :kind, :description, :private?]
defstruct [:name, :relationship_path, :filter, :kind, :description, :private?, :field, :sort]
@schema [
name: [
@ -18,11 +18,21 @@ defmodule Ash.Resource.Aggregate do
doc: "The kind of the aggregate",
required: true
],
field: [
type: :atom,
doc:
"The field to aggregate. Defaults to the first field in the primary key of the resource",
required: false
],
filter: [
type: :keyword_list,
doc: "A filter to apply to the aggregate",
default: []
],
sort: [
type: :any,
doc: "A sort to be applied to the aggregate"
],
description: [
type: :string,
doc: "An optional description for the aggregate"
@ -39,7 +49,8 @@ defmodule Ash.Resource.Aggregate do
name: atom(),
relationship_path: {:ok, list(atom())} | {:error, String.t()},
filter: Keyword.t(),
kind: :count,
field: atom,
kind: Ash.Query.Aggregate.kind(),
description: String.t() | nil,
private?: boolean
}

View file

@ -563,7 +563,7 @@ defmodule Ash.Resource.Dsl do
@count %Ash.Dsl.Entity{
name: :count,
describe: """
Declares a named aggregate on the resource
Declares a named count aggregate on the resource
""",
examples: [
"""
@ -578,6 +578,24 @@ defmodule Ash.Resource.Dsl do
auto_set_fields: [kind: :count]
}
@first %Ash.Dsl.Entity{
name: :first,
describe: """
Declares a named aggregate on the resource
""",
examples: [
"""
count :assigned_ticket_count, :reported_tickets do
filter [active: true]
end
"""
],
target: Ash.Resource.Aggregate,
args: [:name, :relationship_path, :field],
schema: Ash.Resource.Aggregate.schema(),
auto_set_fields: [kind: :first]
}
@aggregates %Ash.Dsl.Section{
name: :aggregates,
describe: """
@ -596,7 +614,8 @@ defmodule Ash.Resource.Dsl do
"""
],
entities: [
@count
@count,
@first
]
}

View file

@ -32,7 +32,7 @@ defmodule Ash.Schema do
field(:__metadata__, :map, virtual: true, default: %{})
for aggregate <- Ash.Resource.aggregates(__MODULE__) do
{:ok, type} = Aggregate.kind_to_type(aggregate.kind)
{:ok, type} = Aggregate.kind_to_type(aggregate.kind, :string)
field(aggregate.name, Ash.Type.ecto_type(type),
virtual: true,

View file

@ -195,17 +195,6 @@ defmodule Ash.Actions.PaginationTest do
assert %{results: [%{name: "4"}]} = Api.page!(page, :first)
end
test "the last page can be fetched if the count was not requested" do
assert %{results: [%{name: "3"}]} =
page =
User
|> Ash.Query.sort(name: :desc)
|> Ash.Query.filter(name in ["4", "3", "2", "1", "0"])
|> Api.read!(page: [offset: 1, limit: 1])
assert %{results: [%{name: "0"}]} = Api.page!(page, :last)
end
test "the last page can be fetched if the count was requested" do
assert %{results: [%{name: "3"}]} =
page =
@ -370,17 +359,6 @@ defmodule Ash.Actions.PaginationTest do
assert %{results: [%{name: "3"}]} = page = Api.page!(page, :next)
assert %{results: [%{name: "4"}]} = Api.page!(page, :first)
end
test "the last page can be fetched" do
assert %{results: [%{name: "3"}]} =
page =
User
|> Ash.Query.sort(name: :desc)
|> Ash.Query.filter(name in ["4", "3", "2", "1", "0"])
|> Api.read!(page: [offset: 1, limit: 1])
assert %{results: [%{name: "0"}]} = Api.page!(page, :last)
end
end
describe "when both are supported" do