Skip to content

Commit

Permalink
fix: properly hydrate and scope sorts
Browse files Browse the repository at this point in the history
improvement: support anonymous aggregates and calculations in queries
  • Loading branch information
zachdaniel committed May 22, 2024
1 parent ac9afaf commit 9b3eace
Show file tree
Hide file tree
Showing 13 changed files with 378 additions and 177 deletions.
329 changes: 272 additions & 57 deletions lib/ash/actions/read/read.ex

Large diffs are not rendered by default.

148 changes: 63 additions & 85 deletions lib/ash/actions/sort.ex
Original file line number Diff line number Diff line change
Expand Up @@ -73,56 +73,62 @@ defmodule Ash.Actions.Sort do
{%Ash.Query.Calculation{sortable?: false} = calc, _order}, {sorts, errors} ->
{sorts, [UnsortableField.exception(resource: resource, field: calc) | errors]}

{%Ash.Query.Calculation{
name: :__expr_sort__,
module: Ash.Resource.Calculation.Expression,
opts: [{:expr, expr}]
} = calc, order},
{sorts, errors} ->
expr
|> Ash.Filter.list_refs()
|> Enum.reduce_while(:ok, fn %{relationship_path: path, attribute: attribute}, :ok ->
{ref_attribute, field_name} =
case attribute do
atom when is_atom(attribute) ->
{Ash.Resource.Info.field(Ash.Resource.Info.related(resource, path), attribute),
atom}

%struct{} = attribute when struct in [Ash.Query.Aggregate, Ash.Query.Calculation] ->
{attribute, attribute}

other ->
{other, other.name}
end
{%Ash.Query.Calculation{} = calc, order}, {sorts, errors} ->
if String.starts_with?(to_string(calc.name), "__expr_sort__") do
%{opts: [{:expr, expr}]} = calc

expr
|> Ash.Filter.list_refs()
|> Enum.reduce_while(:ok, fn %{relationship_path: path, attribute: attribute}, :ok ->
{ref_attribute, field_name} =
case attribute do
atom when is_atom(attribute) ->
{Ash.Resource.Info.field(Ash.Resource.Info.related(resource, path), attribute),
atom}

%struct{} = attribute
when struct in [Ash.Query.Aggregate, Ash.Query.Calculation] ->
{attribute, attribute}

other ->
{other, other.name}
end

if ref_attribute.sortable? do
case find_non_sortable_relationship(resource, path, sort) do
nil ->
{:cont, :ok}
if ref_attribute.sortable? do
case find_non_sortable_relationship(resource, path, sort) do
nil ->
{:cont, :ok}

{resource, non_sortable_field} ->
{:halt, {:error, resource, non_sortable_field}}
end
else
{:halt, {:error, resource, field_name}}
end
end)
|> case do
:ok ->
if order in @sort_orders do
{sorts ++ [{calc, order}], errors}
{resource, non_sortable_field} ->
{:halt, {:error, resource, non_sortable_field}}
end
else
{sorts, [InvalidSortOrder.exception(order: order) | errors]}
{:halt, {:error, resource, field_name}}
end
end)
|> case do
:ok ->
if order in @sort_orders do
{sorts ++ [{calc, order}], errors}
else
{sorts, [InvalidSortOrder.exception(order: order) | errors]}
end

{:error, resource, non_sortable_field} ->
{sorts,
[UnsortableField.exception(resource: resource, field: non_sortable_field) | errors]}
{:error, resource, non_sortable_field} ->
{sorts,
[UnsortableField.exception(resource: resource, field: non_sortable_field) | errors]}
end
else
if order in @sort_orders do
{sorts ++ [{calc, order}], errors}
else
{sorts, [InvalidSortOrder.exception(order: order) | errors]}
end
end

{%Ash.Query.Calculation{} = calc, order}, {sorts, errors} ->
{%{__struct__: Ash.Query.Aggregate} = agg, order}, {sorts, errors} ->
if order in @sort_orders do
{sorts ++ [{calc, order}], errors}
{sorts ++ [{agg, order}], errors}
else
{sorts, [InvalidSortOrder.exception(order: order) | errors]}
end
Expand Down Expand Up @@ -178,7 +184,7 @@ defmodule Ash.Actions.Sort do
end

!attribute ->
{sorts, [NoSuchField.exception(attribute: field, resource: resource) | errors]}
{sorts, [NoSuchField.exception(field: field, resource: resource) | errors]}

!attribute.sortable? ->
{sorts,
Expand Down Expand Up @@ -371,15 +377,15 @@ defmodule Ash.Actions.Sort do

results
|> load_field(field, resource, opts)
|> Enum.sort_by(&resolve_field(&1, field, resource, domain: opts), to_sort_by_fun(direction))
|> Enum.sort_by(&resolve_field(&1, field), to_sort_by_fun(direction))
end

def runtime_sort(results, [{field, direction} | rest], opts) do
resource = get_resource(results, opts)

results
|> load_field(field, resource, opts)
|> Enum.group_by(&resolve_field(&1, field, resource, domain: opts))
|> Enum.group_by(&resolve_field(&1, field))
|> Enum.sort_by(fn {key, _value} -> key end, to_sort_by_fun(direction))
|> Enum.flat_map(fn {_, records} ->
runtime_sort(records, rest, Keyword.put(opts, :rekey?, false))
Expand Down Expand Up @@ -422,7 +428,7 @@ defmodule Ash.Actions.Sort do
def runtime_distinct([%resource{} | _] = results, [{field, direction} | rest], opts) do
results
|> load_field(field, resource, opts)
|> Enum.group_by(&resolve_field(&1, field, resource, domain: opts))
|> Enum.group_by(&resolve_field(&1, field))
|> Enum.sort_by(fn {key, _value} -> key end, to_sort_by_fun(direction))
|> Enum.map(fn {_key, [first | _]} ->
first
Expand Down Expand Up @@ -453,48 +459,20 @@ defmodule Ash.Actions.Sort do
end
end

defp resolve_field(record, %Ash.Query.Calculation{} = calc, resource, opts) do
cond do
calc.module.has_calculate?() ->
context = Map.put(calc.context, :domain, opts[:domain])

case calc.module.calculate([record], calc.opts, context) do
{:ok, [value]} -> value
_ -> nil
end

calc.module.has_expression?() ->
expression = calc.module.expression(calc.opts, calc.context)

case Ash.Filter.hydrate_refs(expression, %{
resource: resource,
aggregates: %{},
calculations: %{},
public?: false
}) do
{:ok, expression} ->
case Ash.Expr.eval_hydrated(expression, record: record, resource: resource) do
{:ok, value} ->
value

_ ->
nil
end

_ ->
nil
end

true ->
nil
end
|> case do
%Ash.ForbiddenField{} -> nil
other -> other
defp resolve_field(record, %{__struct__: struct} = agg)
when struct in [Ash.Query.Calculation, Ash.Query.Aggregate] do
if agg.load do
Map.get(record, agg.load)
else
if struct == Ash.Query.Calculation do
Map.get(record.calculations, agg.name)
else
Map.get(record.aggregates, agg.name)
end
end
end

defp resolve_field(record, field, _resource, _) do
defp resolve_field(record, field) do
record
|> Map.get(field)
|> case do
Expand Down
6 changes: 4 additions & 2 deletions lib/ash/filter/filter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2844,7 +2844,8 @@ defmodule Ash.Filter do
end
end

defp add_expression_part({%Ash.Query.Calculation{} = calc, rest}, context, expression) do
defp add_expression_part({%{__struct__: field_struct} = calc, rest}, context, expression)
when field_struct in [Ash.Query.Calculation, Ash.Query.Aggregate] do
case parse_predicates(rest, calc, context) do
{:ok, nested_statement} ->
{:ok, BooleanExpression.optimized_new(:and, expression, nested_statement)}
Expand All @@ -2854,7 +2855,8 @@ defmodule Ash.Filter do
end
end

defp add_expression_part(%Ash.Query.Calculation{} = calc, _context, expression) do
defp add_expression_part(%{__struct__: field_struct} = calc, _context, expression)
when field_struct in [Ash.Query.Calculation, Ash.Query.Aggregate] do
{:ok, BooleanExpression.optimized_new(:and, calc, expression)}
end

Expand Down
12 changes: 10 additions & 2 deletions lib/ash/page/keyset.ex
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ defmodule Ash.Page.Keyset do

field =
case field do
%Ash.Query.Calculation{} = calc ->
%{__struct__: field_struct} = calc
when field_struct in [Ash.Query.Calculation, Ash.Query.Aggregate] ->
calc

field ->
Expand Down Expand Up @@ -199,13 +200,20 @@ defmodule Ash.Page.Keyset do

defp field_values(record, sort) do
Enum.map(sort, fn
{%Ash.Query.Calculation{load: load, name: name}, _} ->
{%{__struct__: Ash.Query.Calculation, load: load, name: name}, _} ->
if load do
Map.get(record, load)
else
Map.get(record.calculations, name)
end

{%{__struct__: Ash.Query.Aggregate, load: load, name: name}, _} ->
if load do
Map.get(record, load)
else
Map.get(record.aggregates, name)
end

{field, _} ->
Map.get(record, field)
end)
Expand Down
21 changes: 21 additions & 0 deletions lib/ash/query/query.ex
Original file line number Diff line number Diff line change
Expand Up @@ -342,8 +342,28 @@ defmodule Ash.Query do
add_error(query, :sort, "Data layer does not support sorting")
end
end
|> sequence_expr_sorts()
end

# sobelow_skip ["DOS.BinToAtom", "DOS.StringToAtom"]
defp sequence_expr_sorts(%{sort: sort} = query) when is_list(sort) and sort != [] do
%{
query
| sort:
query.sort
|> Enum.with_index()
|> Enum.map(fn
{{%Ash.Query.Calculation{name: :__expr_sort__} = field, direction}, index} ->
{%{field | name: String.to_atom("__expr_sort__#{index}"), load: nil}, direction}

{other, _} ->
other
end)
}
end

defp sequence_expr_sorts(query), do: query

@doc """
Attach a filter statement to the query.
Expand Down Expand Up @@ -2716,6 +2736,7 @@ defmodule Ash.Query do
add_error(query, :sort, "Data layer does not support sorting")
end
end
|> sequence_expr_sorts()
end

@doc """
Expand Down
23 changes: 0 additions & 23 deletions lib/ash/query/ref.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,6 @@ defmodule Ash.Query.Ref do

defimpl Inspect do
def inspect(ref, _opts) do
case ref.attribute do
%Ash.Query.Calculation{} ->
case Map.drop(ref.attribute.context || %{}, [:context, :ash]) do
empty when empty == %{} ->
inspect_ref(ref)

args ->
inspect(%Ash.Query.Call{
name: ref.attribute.name,
relationship_path: ref.relationship_path,
args: [args]
})
end

%Ash.Query.Aggregate{} ->
inspect_ref(ref)

_ ->
inspect_ref(ref)
end
end

defp inspect_ref(ref) do
name =
case ref.attribute do
%{name: name} -> name
Expand Down
2 changes: 2 additions & 0 deletions lib/ash/query/type.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ defmodule Ash.Query.Type do

def try_cast(value, type, constraints \\ [])

def try_cast(value, nil, _constraints), do: {:ok, value}

def try_cast(list, {:array, type}, constraints) do
if Enumerable.impl_for(list) do
list
Expand Down
2 changes: 0 additions & 2 deletions lib/ash/resource/calculation/expression.ex
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,6 @@ defmodule Ash.Resource.Calculation.Expression do
Enum.reduce_while(records, {:ok, []}, fn record, {:ok, values} ->
case Ash.Filter.hydrate_refs(expression, %{
resource: resource,
aggregates: %{},
calculations: %{},
public?: false
}) do
{:ok, expression} ->
Expand Down
2 changes: 1 addition & 1 deletion lib/ash/resource/dsl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1430,7 +1430,7 @@ defmodule Ash.Resource.Dsl do
Ash.Resource.Transformers.ManyToManyDestinationAttributeOnJoinResource,
Ash.Resource.Transformers.CreateJoinRelationship,
Ash.Resource.Transformers.CachePrimaryKey,
Ash.Resource.Transformers.ValidatePrimaryActions,
Ash.Resource.Transformers.SetPrimaryActions,
Ash.Resource.Transformers.DefaultAccept,
Ash.Resource.Transformers.RequireUniqueFieldNames,
Ash.Resource.Transformers.SetDefineFor,
Expand Down
2 changes: 1 addition & 1 deletion lib/ash/resource/transformers/default_accept.ex
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,6 @@ defmodule Ash.Resource.Transformers.DefaultAccept do

def after?(Ash.Resource.Transformers.BelongsToAttribute), do: true
def after?(Ash.Resource.Transformers.CreateJoinRelationship), do: true
def after?(Ash.Resource.Transformers.ValidatePrimaryActions), do: true
def after?(Ash.Resource.Transformers.SetPrimaryActions), do: true
def after?(_), do: false
end
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@ defmodule Ash.Resource.Transformers.RequireUniqueActionNames do
{:ok, dsl_state}
end

def after?(Ash.Resource.Transformers.ValidatePrimaryActions), do: true
def after?(Ash.Resource.Transformers.SetPrimaryActions), do: true
def after?(_), do: false
end
2 changes: 1 addition & 1 deletion lib/ash/resource/transformers/set_primary_actions.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule Ash.Resource.Transformers.ValidatePrimaryActions do
defmodule Ash.Resource.Transformers.SetPrimaryActions do
@moduledoc """
Validates the primary action configuration
Expand Down
4 changes: 2 additions & 2 deletions lib/ash/sort/sort.ex
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ defmodule Ash.Sort do
For example:
```elixir
Ash.Query.sort(Ash.Sort.expr_sort(author.full_name, :string))
Ash.Query.sort(query, Ash.Sort.expr_sort(author.full_name, :string))
Ash.Query.sort([{Ash.Sort.expr_sort(author.full_name, :string), :desc_nils_first}])
Ash.Query.sort(query, [{Ash.Sort.expr_sort(author.full_name, :string), :desc_nils_first}])
```
"""
@spec expr_sort(Ash.Expr.t(), Ash.Type.t() | nil) :: Ash.Expr.t()
Expand Down

0 comments on commit 9b3eace

Please sign in to comment.