feat: expression calculations for sorting/filtering

improvement: small improvements/fixes across the board
This commit is contained in:
Zach Daniel 2021-06-04 01:37:11 -04:00
parent c0cd039ae2
commit 231eeafd30
44 changed files with 1753 additions and 491 deletions

View file

@ -76,6 +76,7 @@
{Credo.Check.Consistency.SpaceInParentheses, []},
{Credo.Check.Consistency.TabsOrSpaces, []},
#
## Design Checks
#
@ -100,7 +101,7 @@
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, []},
{Credo.Check.Readability.ModuleNames, []},
{Credo.Check.Readability.ParenthesesInCondition, []},
{Credo.Check.Readability.ParenthesesInCondition, false},
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
{Credo.Check.Readability.PredicateFunctionNames, []},
{Credo.Check.Readability.PreferImplicitTry, []},

View file

@ -62,6 +62,7 @@ locals_without_parens = [
kind: 1,
list: 3,
list: 4,
load: 1,
manual?: 1,
many_to_many: 2,
many_to_many: 3,

View file

@ -60,7 +60,7 @@ Example:
```elixir
User
|> Ash.Query.new()
|> Ash.Query.calculate(:full_name, {Concat, keys: [:first_name, :last_name]}, %{separator: ","})
|> Ash.Query.calculate(:full_name, {Concat, keys: [:first_name, :last_name]}, :string, %{separator: ","})
```
See the documentation for `Ash.Query.calculate/4` for more information.

View file

@ -32,6 +32,13 @@ defmodule Ash.Actions.Create do
|> Map.get(:keys)
end
changeset =
if opts[:tenant] do
Ash.Changeset.set_tenant(changeset, opts[:tenant])
else
changeset
end
resource = changeset.resource
opts =

View file

@ -37,6 +37,13 @@ defmodule Ash.Actions.Destroy do
|> Keyword.take([:verbose?, :actor, :authorize?])
|> Keyword.put(:transaction?, true)
changeset =
if opts[:tenant] do
Ash.Changeset.set_tenant(changeset, opts[:tenant])
else
changeset
end
opts =
case Map.fetch(changeset.context[:private] || %{}, :actor) do
{:ok, actor} ->

View file

@ -47,7 +47,7 @@ defmodule Ash.Actions.Load do
else
related_query
end
|> maybe_select(relationship.destination_field)
|> Ash.Query.ensure_selected(relationship.destination_field)
related_query =
if relationship.cardinality == :one do
@ -67,7 +67,7 @@ defmodule Ash.Actions.Load do
)
{
maybe_select(query, relationship.source_field),
Ash.Query.ensure_selected(query, relationship.source_field),
requests ++
further_requests ++
do_requests(
@ -80,19 +80,6 @@ defmodule Ash.Actions.Load do
end)
end
defp maybe_select(query, field) do
if query.select do
Ash.Query.select(query, List.wrap(field))
else
to_select =
query.resource
|> Ash.Resource.Info.attributes()
|> Enum.map(& &1.name)
Ash.Query.select(query, to_select)
end
end
def attach_loads([%resource{} | _] = data, %{load: loads}) do
loads
|> Enum.sort_by(fn {key, _value} ->
@ -717,7 +704,7 @@ defmodule Ash.Actions.Load do
reverse_path,
root_data_filter
) do
case Ash.Filter.parse(root_query.resource, root_query.filter) do
case Ash.Filter.parse(root_query.resource, root_query.filter, %{}, %{}) do
{:ok, nil} ->
related_query
|> Ash.Query.unset(:load)

View file

@ -50,6 +50,13 @@ defmodule Ash.Actions.Read do
opts[:authorize?] || Keyword.has_key?(opts, :actor)
end
query =
if opts[:tenant] do
Ash.Query.set_tenant(query, opts[:tenant])
else
query
end
action =
cond do
action && is_atom(action) ->
@ -84,21 +91,17 @@ defmodule Ash.Actions.Read do
initial_limit = query.limit
initial_offset = query.offset
load = (opts[:page] || nil)[:load]
with %{valid?: true} <- query,
:ok <- validate_multitenancy(query, opts),
%{errors: []} = query <- query_with_initial_data(query, opts),
{:ok, filter_requests} <- filter_requests(query, opts),
{:ok, query, page_opts} <-
paginate(query, action, opts),
page_opts <- page_opts && Keyword.delete(page_opts, :filter),
{:ok, requests} <-
requests(query, action, filter_requests, initial_limit, initial_offset, opts),
{query, load_requests} <- Load.requests(query, load),
requests(query, action, initial_limit, initial_offset, opts),
{:ok, %{data: %{data: data} = all_data}} <-
Engine.run(
requests ++ load_requests,
requests,
query.api,
engine_opts
),
@ -109,9 +112,16 @@ defmodule Ash.Actions.Read do
data_with_loads,
query.aggregates,
query.resource,
Map.get(all_data, :aggregate_values, %{})
Map.get(all_data, :aggregate_values) || %{},
Map.get(all_data, :aggregates_in_query) || []
),
{:ok, with_calculations} <-
add_calculation_values(
Map.get(all_data, :ultimate_query) || query,
data_with_aggregates,
Map.get(all_data, :calculations_at_runtime) || []
) do
data_with_aggregates
with_calculations
|> add_tenant(query)
|> add_page(
action,
@ -256,32 +266,35 @@ defmodule Ash.Actions.Read do
end
end
defp requests(query, action, filter_requests, initial_limit, initial_offset, opts) do
authorizing? =
if opts[:authorize?] == false do
false
else
Keyword.has_key?(opts, :actor) || opts[:authorize?]
end
can_be_in_query? = not Keyword.has_key?(opts, :initial_data)
{aggregate_auth_requests, aggregate_value_requests, aggregates_in_query} =
Aggregate.requests(query, can_be_in_query?, authorizing?)
defp requests(query, action, initial_limit, initial_offset, opts) do
request =
Request.new(
resource: query.resource,
api: query.api,
query:
Request.resolve([], fn _ ->
multitenancy_attribute = Ash.Resource.Info.multitenancy_attribute(query.resource)
{query, before_notifications} =
if multitenancy_attribute && query.tenant do
{m, f, a} = Ash.Resource.Info.multitenancy_parse_attribute(query.resource)
attribute_value = apply(m, f, [query.tenant | a])
Ash.Query.filter(query, [{multitenancy_attribute, attribute_value}])
else
query
end
|> run_before_action()
{query, load_requests} = Load.requests(query)
case Filter.run_other_data_layer_filters(
query.api,
query.resource,
query.filter
) do
{:ok, filter} ->
{:ok, %{query | filter: filter}}
{:ok, %{query | filter: filter},
%{requests: load_requests, notifications: before_notifications}}
{:error, error} ->
{:error, error}
@ -292,9 +305,6 @@ defmodule Ash.Actions.Read do
data:
data_field(
opts,
filter_requests,
aggregate_auth_requests,
aggregates_in_query,
initial_limit,
initial_offset,
query
@ -303,138 +313,216 @@ defmodule Ash.Actions.Read do
name: "#{action.type} - `#{action.name}`"
)
{:ok, [request | filter_requests] ++ aggregate_auth_requests ++ aggregate_value_requests}
{:ok, [request]}
end
defp data_field(
params,
filter_requests,
aggregate_auth_requests,
aggregates_in_query,
initial_limit,
initial_offset,
initial_query
) do
if params[:initial_data] do
Request.resolve([], fn _ ->
add_calculation_values(initial_query, params[:initial_data], initial_query.calculations)
end)
else
relationship_filter_paths =
Enum.map(filter_requests, fn request ->
request.path ++ [:authorization_filter]
end)
Request.resolve(
[[:data, :query]],
fn %{data: %{query: ash_query}} = data ->
used_calculations =
ash_query.filter
|> Ash.Filter.used_calculations(
ash_query.resource,
[],
ash_query.calculations,
ash_query.aggregates
)
aggregate_auth_paths =
Enum.map(aggregate_auth_requests, fn request ->
request.path ++ [:authorization_filter]
end)
can_be_in_query? = not Keyword.has_key?(params, :initial_data)
deps = [
[:data, :query]
| relationship_filter_paths ++ aggregate_auth_paths
]
Request.resolve(
deps,
fn %{data: %{query: ash_query}} = data ->
aggregates =
ash_query.resource
|> Ash.Resource.Info.aggregates()
|> Enum.map(& &1.name)
to_load =
ash_query.filter
|> Ash.Filter.used_aggregates()
|> Enum.filter(&(&1.name in aggregates))
|> Enum.map(& &1.name)
ash_query = Ash.Query.load(ash_query, to_load)
multitenancy_attribute = Ash.Resource.Info.multitenancy_attribute(ash_query.resource)
{ash_query, before_notifications} =
if multitenancy_attribute && ash_query.tenant do
{m, f, a} = Ash.Resource.Info.multitenancy_parse_attribute(ash_query.resource)
attribute_value = apply(m, f, [ash_query.tenant | a])
Ash.Query.filter(ash_query, [{multitenancy_attribute, attribute_value}])
else
ash_query
end
|> run_before_action()
query =
initial_query
|> Ash.Query.unset([:filter, :aggregates, :sort, :limit, :offset])
|> Ash.Query.data_layer_query(only_validate_filter?: true)
with %{valid?: true} <- ash_query,
{:ok, query} <- query,
{:ok, filter} <-
filter_with_related(relationship_filter_paths, ash_query, data),
{:ok, query} <-
Ash.DataLayer.select(
query,
Helpers.attributes_to_select(ash_query),
ash_query.resource
),
{:ok, query} <-
add_aggregates(
query,
ash_query,
aggregates_in_query,
Map.get(data, :aggregate, %{})
),
{:ok, query} <-
Ash.DataLayer.filter(query, filter, ash_query.resource),
{:ok, query} <-
Ash.DataLayer.sort(query, ash_query.sort, ash_query.resource),
{:ok, query} <-
Ash.DataLayer.distinct(query, ash_query.distinct, ash_query.resource),
{:ok, query} <-
Ash.DataLayer.set_context(ash_query.resource, query, ash_query.context),
{:ok, count} <-
fetch_count(
ash_query,
query,
ash_query.resource,
ash_query.action,
initial_limit,
initial_offset,
params
),
{:ok, query} <- Ash.DataLayer.limit(query, ash_query.limit, ash_query.resource),
{:ok, query} <- Ash.DataLayer.offset(query, ash_query.offset, ash_query.resource),
{:ok, query} <- set_tenant(query, ash_query),
{:ok, results} <- run_query(ash_query, query),
{:ok, results, after_notifications} <- run_after_action(initial_query, results),
{:ok, with_calculations} <-
add_calculation_values(ash_query, results, ash_query.calculations),
{:ok, count} <- maybe_await(count) do
if params[:return_query?] do
ultimate_query =
ash_query
|> Ash.Query.unset(:filter)
|> Ash.Query.filter(filter)
{:ok, with_calculations,
%{
notifications: before_notifications ++ after_notifications,
extra_data: %{ultimate_query: ultimate_query, count: count}
}}
else
{:ok, with_calculations, %{extra_data: %{count: count}}}
end
authorizing? =
if params[:authorize?] == false do
false
else
%{valid?: false} = query ->
{:error, query}
other ->
other
params[:authorize?] || Keyword.has_key?(params, :actor)
end
filter_requests = filter_requests(ash_query, params)
{calculations_in_query, calculations_at_runtime} =
if Ash.DataLayer.data_layer_can?(ash_query.resource, :expression_calculation) &&
!params[:initial_data] do
Enum.split_with(ash_query.calculations, fn {_name, calculation} ->
Enum.find(used_calculations, &(&1.name == calculation.name)) ||
calculation.name in Enum.map(ash_query.sort || [], &elem(&1, 0)) ||
!:erlang.function_exported(calculation.module, :calculate, 3)
end)
else
{[], ash_query.calculations}
end
{aggregate_auth_requests, aggregate_value_requests, aggregates_in_query} =
Aggregate.requests(
ash_query,
can_be_in_query?,
authorizing?,
Enum.map(calculations_in_query, &elem(&1, 1))
)
cond do
match?({:error, _error}, filter_requests) ->
filter_requests
# if aggregate auth requests is not empty but we have not received the data from
# those requests, we should ask the engine to run the aggregate value requests
!Enum.empty?(aggregate_auth_requests) && !data[:aggregate] ->
{:requests, Enum.map(aggregate_auth_requests, &{&1, :authorization_filter})}
!match?({:ok, []}, filter_requests) && !data[:filter] ->
{:ok, filter_requests} = filter_requests
{:requests, Enum.map(filter_requests, &{&1, :authorization_filter})}
true ->
if params[:initial_data] do
add_calculation_values(
initial_query,
params[:initial_data],
initial_query.calculations
)
else
query =
initial_query
|> Ash.Query.unset([:filter, :aggregates, :sort, :limit, :offset])
|> Ash.Query.data_layer_query(only_validate_filter?: true)
ash_query =
if ash_query.select || calculations_at_runtime == %{} do
ash_query
else
to_select =
ash_query.resource
|> Ash.Resource.Info.attributes()
|> Enum.map(& &1.name)
Ash.Query.select(ash_query, to_select)
end
ash_query =
Enum.reduce(calculations_at_runtime, ash_query, fn {_, calculation}, ash_query ->
if calculation.select do
Ash.Query.select(ash_query, calculation.select || [])
else
ash_query
end
end)
{:ok, filter_requests} = filter_requests
with %{valid?: true} <- ash_query,
{:ok, query} <- query,
{:ok, filter} <-
filter_with_related(Enum.map(filter_requests, & &1.path), ash_query, data),
filter <- update_aggregate_filters(filter, data),
{:ok, query} <-
Ash.DataLayer.set_context(ash_query.resource, query, ash_query.context),
{:ok, query} <-
Ash.DataLayer.select(
query,
Helpers.attributes_to_select(ash_query),
ash_query.resource
),
{:ok, query} <-
add_aggregates(
query,
ash_query,
aggregates_in_query,
Map.get(data, :aggregate, %{})
),
{:ok, query} <-
add_calculations(
query,
ash_query,
Enum.map(calculations_in_query, &elem(&1, 1))
),
{:ok, query} <-
Ash.DataLayer.filter(
query,
filter,
ash_query.resource
),
{:ok, query} <-
Ash.DataLayer.sort(query, ash_query.sort, ash_query.resource),
{:ok, query} <-
Ash.DataLayer.distinct(query, ash_query.distinct, ash_query.resource),
{:ok, count} <-
fetch_count(
ash_query,
query,
ash_query.resource,
ash_query.action,
initial_limit,
initial_offset,
params
),
{:ok, query} <-
Ash.DataLayer.limit(query, ash_query.limit, ash_query.resource),
{:ok, query} <-
Ash.DataLayer.offset(query, ash_query.offset, ash_query.resource),
{:ok, query} <- set_tenant(query, ash_query),
{:ok, results} <- run_query(ash_query, query),
{:ok, results, after_notifications} <-
run_after_action(initial_query, results),
{:ok, count} <- maybe_await(count) do
if params[:return_query?] do
ultimate_query =
ash_query
|> Ash.Query.unset(:filter)
|> Ash.Query.filter(filter)
{:ok, results,
%{
notifications: after_notifications,
requests: aggregate_value_requests,
extra_data: %{
ultimate_query: ultimate_query,
count: count,
calculations_at_runtime: calculations_at_runtime,
aggregates_in_query: aggregates_in_query
}
}}
else
{:ok, results,
%{
notifications: after_notifications,
requests: aggregate_value_requests,
extra_data: %{
count: count,
calculations_at_runtime: calculations_at_runtime,
aggregates_in_query: aggregates_in_query
}
}}
end
else
%{valid?: false} = query ->
{:error, query}
other ->
other
end
end
end
)
end
end
)
end
defp update_aggregate_filters(filter, data) do
Filter.update_aggregates(filter, fn aggregate, ref ->
case data[:aggregate][ref.relationship_path ++ aggregate.relationship_path][
:authorization_filter
] do
nil ->
aggregate
authorization_filter ->
%{aggregate | authorization_filter: authorization_filter}
end
end)
end
defp maybe_await(%Task{} = task) do
@ -714,11 +802,16 @@ defmodule Ash.Actions.Read do
end
defp add_calculation_values(query, results, calculations) do
calculations
|> Enum.reduce_while({:ok, %{}}, fn {_name, calculation}, {:ok, calculation_results} ->
context = Map.put(calculation.context, :context, query.context)
{can_be_runtime, require_query} =
calculations
|> Enum.map(&elem(&1, 1))
|> Enum.split_with(fn calculation ->
:erlang.function_exported(calculation.module, :calculate, 3)
end)
case calculation.module.calculate(results, calculation.opts, context) do
can_be_runtime
|> Enum.reduce_while({:ok, %{}}, fn calculation, {:ok, calculation_results} ->
case calculation.module.calculate(results, calculation.opts, calculation.context) do
results when is_list(results) ->
{:cont, {:ok, Map.put(calculation_results, calculation, results)}}
@ -753,13 +846,67 @@ defmodule Ash.Actions.Read do
{:error, error} ->
{:error, error}
end
|> run_calculation_query(require_query, query)
end
defp add_aggregate_values(results, _aggregates, _resource, aggregate_values)
when aggregate_values == %{},
do: Enum.map(results, &Map.update!(&1, :aggregates, fn agg -> agg || %{} end))
defp run_calculation_query({:ok, results}, [], _), do: {:ok, results}
defp add_aggregate_values(results, aggregates, resource, aggregate_values) do
defp run_calculation_query({:ok, results}, calculations, query) do
pkey = Ash.Resource.Info.primary_key(query.resource)
pkey_filter =
results
|> List.wrap()
|> Enum.map(fn result ->
result
|> Map.take(pkey)
|> Map.to_list()
end)
with query <- Ash.Query.unset(query, [:filter, :aggregates, :sort, :limit, :offset]),
query <- Ash.Query.filter(query, ^[or: pkey_filter]),
{:ok, data_layer_query} <- Ash.Query.data_layer_query(query),
{:ok, data_layer_query} <-
add_calculations(data_layer_query, query, calculations),
{:ok, calculation_results} <-
Ash.DataLayer.run_query(
data_layer_query,
query.resource
) do
results_with_calculations =
results
|> Enum.map(fn result ->
result_pkey = Map.take(result, pkey)
match = Enum.find(calculation_results, &(Map.take(&1, pkey) == result_pkey))
if match do
Enum.reduce(calculations, result, fn calculation, result ->
if calculation.load do
Map.put(result, calculation.load, Map.get(match, calculation.load))
else
Map.update!(result, :calculations, fn calculations ->
Map.put(
calculations,
calculation.name,
Map.get(match, :calculations)[calculation.name]
)
end)
end
end)
else
result
end
end)
{:ok, results_with_calculations}
end
end
defp run_calculation_query({:error, error}, _, _) do
{:error, error}
end
defp add_aggregate_values(results, aggregates, resource, aggregate_values, aggregates_in_query) do
keys_to_aggregates =
Enum.reduce(aggregate_values, %{}, fn {_name, keys_to_values}, acc ->
Enum.reduce(keys_to_values, acc, fn {pkey, values}, acc ->
@ -777,12 +924,48 @@ defmodule Ash.Actions.Read do
Enum.map(results, fn result ->
aggregate_values = Map.get(keys_to_aggregates, Map.take(result, pkey), %{})
aggregate_values =
aggregates
|> Enum.reject(fn {name, _aggregate} ->
Enum.find(aggregates_in_query, &(&1.name == name))
end)
|> Enum.reduce(aggregate_values, fn {_, aggregate}, aggregate_values ->
Map.put_new(aggregate_values, aggregate.name, aggregate.default_value)
end)
{top_level, nested} = Map.split(aggregate_values || %{}, loaded)
Map.merge(%{result | aggregates: Map.merge(result.aggregates, nested)}, top_level)
end)
end
defp add_calculations(data_layer_query, query, calculations_to_add) do
Enum.reduce_while(calculations_to_add, {:ok, data_layer_query}, fn calculation,
{:ok, data_layer_query} ->
expression = calculation.module.expression(calculation.opts, calculation.context)
with {:ok, expression} <-
Ash.Filter.hydrate_refs(expression, %{
resource: query.resource,
aggregates: query.aggregates,
calculations: query.calculations,
public?: false
}),
{:ok, query} <-
Ash.DataLayer.add_calculation(
data_layer_query,
calculation,
expression,
query.resource
) do
{:cont, {:ok, query}}
else
other ->
{:halt, other}
end
end)
end
defp add_aggregates(data_layer_query, query, aggregates_to_add, aggregate_filters) do
aggregates_to_add =
Enum.into(aggregates_to_add, %{}, fn aggregate ->
@ -814,7 +997,7 @@ defmodule Ash.Actions.Read do
defp filter_with_related(relationship_filter_paths, ash_query, data) do
Enum.reduce_while(relationship_filter_paths, {:ok, ash_query.filter}, fn path,
{:ok, filter} ->
case get_in(data, path) do
case get_in(data, path ++ [:authorization_filter]) do
nil ->
{:cont, {:ok, filter}}

View file

@ -36,6 +36,13 @@ defmodule Ash.Actions.Update do
|> Keyword.take([:verbose?, :actor, :authorize?])
|> Keyword.put(:transaction?, true)
changeset =
if opts[:tenant] do
Ash.Changeset.set_tenant(changeset, opts[:tenant])
else
changeset
end
resource = changeset.resource
changeset = changeset(changeset, api, action, opts[:actor])

View file

@ -67,9 +67,13 @@ defmodule Ash.Api do
],
stacktraces?: [
type: :boolean,
default: false,
default: true,
doc:
"For Ash errors, can be set to true to get a stacktrace for each error that occured. See the error_handling guide for more."
"For Ash errors, wether or not each error has a stacktrace. See the error_handling guide for more."
],
tenant: [
type: :any,
doc: "A tenant to set on the query or changeset"
],
actor: [
type: :any,

View file

@ -34,7 +34,7 @@ defmodule Ash.Api.Interface do
## Options
#{Ash.OptionsHelpers.docs(Ash.Resource.Interface.interface_options())}
#{Ash.OptionsHelpers.docs(Ash.Resource.Interface.interface_options(action.type))}
"""
case action.type do
@ -166,14 +166,15 @@ defmodule Ash.Api.Interface do
end)
changeset =
unquote(resource)
opts[:changeset]
|> Kernel.||(unquote(resource))
|> Ash.Changeset.for_create(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
unquote(api).create(changeset, opts)
unquote(api).create(changeset, Keyword.drop(opts, [:changeset, :tenant]))
end
end
@ -222,6 +223,7 @@ defmodule Ash.Api.Interface do
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
record,
unquote_splicing(arg_vars),
%{},
params_or_opts
@ -257,6 +259,7 @@ defmodule Ash.Api.Interface do
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
record,
unquote_splicing(arg_vars),
%{},
params_or_opts
@ -292,6 +295,7 @@ defmodule Ash.Api.Interface do
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
record,
unquote_splicing(arg_vars),
%{},
params_or_opts
@ -327,6 +331,7 @@ defmodule Ash.Api.Interface do
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
record,
unquote_splicing(arg_vars),
%{},
params_or_opts

View file

@ -1,33 +1,35 @@
defmodule Ash.Calculation do
@moduledoc "The behaviour for a calculation module"
defmacro __using__(opts) do
type =
opts[:type] ||
raise "Must provide `type` option to `use Ash.Calculation` in #{__CALLER__.module}"
unless Ash.Type.ash_type?(Ash.Type.get_type(opts[:type])) do
raise "Value provided for `type` option must be a valid Ash type"
end
@moduledoc """
The behaviour for a calculation module
Use `select/2` to apply a select statement when the calculation is loaded.
This does not apply in the case that you are loading on existing resources using
`MyApi.load`. It also doesn't apply when the calculation is used in a filter or sort,
because it is not necessary to select fields to power filters done in the data layer.
"""
defmacro __using__(_) do
quote do
@behaviour Ash.Calculation
def init(opts), do: {:ok, opts}
def type, do: unquote(type)
def describe(opts), do: "##{inspect(__MODULE__)}<#{inspect(opts)}>"
def describe(opts), do: inspect({__MODULE__, opts})
def load(query, _opts, _context), do: query
def select(_query, _opts), do: []
def select(_query, _opts, _context), do: []
defoverridable init: 1, type: 0, describe: 1, select: 2
defoverridable init: 1, describe: 1, select: 3, load: 3
end
end
@callback init(Keyword.t()) :: {:ok, Keyword.t()} | {:error, term}
@callback type() :: Ash.Type.t()
@callback describe(Keyword.t()) :: String.t()
@callback calculate([Ash.Resource.record()], Keyword.t(), map) ::
{:ok, [term]} | [term] | {:error, term}
@callback select(Ash.Query.t(), Keyword.t()) :: list(atom)
@callback expression(Keyword.t(), map) :: any
@callback load(Ash.Query.t(), Keyword.t(), map) :: Ash.Query.t()
@callback select(Ash.Query.t(), Keyword.t(), map) :: list(atom)
@optional_callbacks expression: 2, calculate: 3
end

View file

@ -50,6 +50,7 @@ defmodule Ash.DataLayer do
{:ok, data_layer_query()} | {:error, term}
@callback distinct(data_layer_query(), list(atom), resource :: Ash.Resource.t()) ::
{:ok, data_layer_query()} | {:error, term}
@callback set_context(data_layer_query(), map) :: {:ok, data_layer_query()} | {:error, term}
@callback limit(
data_layer_query(),
limit :: non_neg_integer(),
@ -105,6 +106,13 @@ defmodule Ash.DataLayer do
Ash.Resource.t()
) ::
{:ok, data_layer_query()} | {:error, term}
@callback add_calculation(
data_layer_query(),
Ash.Query.Calculation.t(),
expression :: any,
Ash.Resource.t()
) ::
{:ok, data_layer_query()} | {:error, term}
@callback destroy(Ash.Resource.t(), Ash.Changeset.t()) :: :ok | {:error, term}
@callback transaction(Ash.Resource.t(), (() -> term)) :: {:ok, term} | {:error, term}
@callback in_transaction?(Ash.Resource.t()) :: boolean
@ -134,6 +142,8 @@ defmodule Ash.DataLayer do
functions: 1,
in_transaction?: 1,
add_aggregate: 3,
add_calculation: 4,
set_context: 2,
run_aggregate_query: 3,
run_aggregate_query_with_lateral_join: 5,
transform_query: 1,
@ -326,6 +336,18 @@ defmodule Ash.DataLayer do
data_layer.add_aggregate(query, aggregate, resource)
end
@spec add_calculation(
data_layer_query(),
Ash.Query.Calculation.t(),
expression :: term,
Ash.Resource.t()
) ::
{:ok, data_layer_query()} | {:error, term}
def add_calculation(query, calculation, expression, resource) do
data_layer = Ash.DataLayer.data_layer(resource)
data_layer.add_calculation(query, calculation, expression, resource)
end
@spec can?(feature, Ash.Resource.t()) :: boolean
def can?(feature, resource) do
data_layer = Ash.DataLayer.data_layer(resource)

View file

@ -39,7 +39,17 @@ defmodule Ash.DataLayer.Ets do
defmodule Query do
@moduledoc false
defstruct [:resource, :filter, :limit, :sort, :tenant, :api, relationships: %{}, offset: 0]
defstruct [
:resource,
:filter,
:limit,
:sort,
:tenant,
:api,
calculations: [],
relationships: %{},
offset: 0
]
end
@impl true
@ -87,6 +97,10 @@ defmodule Ash.DataLayer.Ets do
@impl true
def offset(query, offset, _), do: {:ok, %{query | offset: offset}}
@impl true
def add_calculation(query, calculation, _, _),
do: {:ok, %{query | calculations: [calculation | query.calculations]}}
@impl true
def set_tenant(_resource, query, tenant) do
{:ok, %{query | tenant: tenant}}
@ -136,6 +150,7 @@ defmodule Ash.DataLayer.Ets do
limit: limit,
sort: sort,
tenant: tenant,
calculations: calculations,
api: api
},
_resource
@ -154,7 +169,52 @@ defmodule Ash.DataLayer.Ets do
offset_records
end
{:ok, limited_records}
if Enum.empty?(calculations) do
{:ok, limited_records}
else
Enum.reduce_while(limited_records, {:ok, []}, fn record, {:ok, records} ->
calculations
|> Enum.reduce_while({:ok, record}, fn calculation, {:ok, record} ->
expression = calculation.module.expression(calculation.opts, calculation.context)
case Ash.Filter.hydrate_refs(expression, %{
resource: resource,
aggregates: %{},
calculations: %{},
public?: false
}) do
{:ok, expression} ->
case Ash.Filter.Runtime.do_match(record, expression) do
{:ok, value} ->
if calculation.load do
{:cont, {:ok, Map.put(record, calculation.load, value)}}
else
{:cont,
{:ok,
Map.update!(record, :calculations, &Map.put(&1, calculation.name, value))}}
end
{:error, error} ->
{:halt, {:error, error}}
end
{:error, error} ->
{:halt, {:error, error}}
end
end)
|> case do
{:ok, record} ->
{:cont, {:ok, [record | records]}}
{:error, error} ->
{:halt, {:error, error}}
end
end)
|> case do
{:ok, records} -> {:ok, Enum.reverse(records)}
{:error, error} -> {:error, error}
end
end
else
{:error, error} -> {:error, error}
end

View file

@ -44,8 +44,8 @@ defmodule Ash.Engine do
:authorize?,
:changeset,
:runner_pid,
:local_requests?,
:runner_ref,
local_requests: [],
request_handlers: %{},
active_requests: [],
completed_requests: [],
@ -92,11 +92,11 @@ defmodule Ash.Engine do
opts
|> Keyword.put(:runner_ref, runner_ref)
|> Keyword.put(:requests, async_requests)
|> Keyword.put(:local_requests?, !Enum.empty?(local_requests))
|> Keyword.put(:local_requests, Enum.map(local_requests, & &1.path))
|> Keyword.put(:runner_pid, self())
|> Keyword.put(:api, api)
run_requests(async_requests, local_requests, opts, innermost_resource)
run_requests(local_requests, opts, innermost_resource)
end)
case transaction_result do
@ -110,25 +110,22 @@ defmodule Ash.Engine do
end
end
defp run_requests(async_requests, local_requests, opts, innermost_resource) do
if async_requests == [] do
run_and_return_or_rollback(local_requests, opts, innermost_resource)
else
Process.flag(:trap_exit, true)
defp run_requests(local_requests, opts, innermost_resource) do
Process.flag(:trap_exit, true)
runner_ref = opts[:runner_ref]
{:ok, pid} = GenServer.start(__MODULE__, opts)
_ = Process.monitor(pid)
{:ok, pid} = GenServer.start(__MODULE__, opts)
_ = Process.monitor(pid)
receive do
{:pid_info, pid_info} ->
run_and_return_or_rollback(
local_requests,
opts,
innermost_resource,
pid,
pid_info
)
end
receive do
{:pid_info, pid_info, ^runner_ref} ->
run_and_return_or_rollback(
local_requests,
opts,
innermost_resource,
pid,
pid_info
)
end
end
@ -136,8 +133,8 @@ defmodule Ash.Engine do
local_requests,
opts,
innermost_resource,
pid \\ nil,
pid_info \\ %{}
pid,
pid_info
) do
case Runner.run(local_requests, opts[:verbose?], opts[:runner_ref], pid, pid_info) do
%{errors: errors} = runner when errors == [] ->
@ -194,7 +191,7 @@ defmodule Ash.Engine do
requests: opts[:requests],
active_requests: Enum.map(opts[:requests], & &1.path),
runner_pid: opts[:runner_pid],
local_requests?: opts[:local_requests?],
local_requests: opts[:local_requests],
verbose?: opts[:verbose?] || false,
api: opts[:api],
actor: opts[:actor],
@ -236,8 +233,8 @@ defmodule Ash.Engine do
{path, pid}
end)
if new_state.local_requests? do
send(new_state.runner_pid, {:pid_info, pid_info})
if new_state.local_requests != [] do
send(new_state.runner_pid, {:pid_info, pid_info, state.runner_ref})
end
Enum.each(new_state.request_handlers, fn {pid, _} ->
@ -306,11 +303,6 @@ defmodule Ash.Engine do
|> maybe_shutdown()
end
def handle_cast(:local_requests_complete, state) do
%{state | local_requests?: false}
|> maybe_shutdown()
end
def handle_cast({:error, error, request_handler_state}, state) do
state
|> log(fn -> "Error received from request_handler #{inspect(error)}" end)
@ -319,6 +311,30 @@ defmodule Ash.Engine do
|> maybe_shutdown()
end
def handle_cast({:new_requests, requests}, state) do
requests =
requests
|> Enum.map(
&%{
&1
| authorize?: &1.authorize? and state.authorize?,
actor: state.actor,
verbose?: state.verbose?
}
)
|> Enum.map(&Request.add_initial_authorizer_state/1)
send(state.runner_pid, {state.runner_ref, {:requests, requests}})
%{state | local_requests: state.local_requests ++ Enum.map(requests, & &1.path)}
|> maybe_shutdown()
end
def handle_cast({:local_request_complete, path}, state) do
%{state | local_requests: state.local_requests -- [path]}
|> maybe_shutdown()
end
def handle_info({:EXIT, _pid, {:shutdown, {:error, error, request_handler_state}}}, state) do
state
|> log(fn -> "Error received from request_handler #{inspect(error)}" end)
@ -450,7 +466,7 @@ defmodule Ash.Engine do
not Ash.DataLayer.data_layer_can?(request.resource, :async_engine)
end
defp maybe_shutdown(%{active_requests: [], local_requests?: false} = state) do
defp maybe_shutdown(%{active_requests: [], local_requests: []} = state) do
log(state, fn -> "shutting down, completion criteria reached" end)
{:stop, {:shutdown, state}, state}
end

View file

@ -39,7 +39,6 @@ defmodule Ash.Engine.Request do
alias Ash.Authorizer
alias Ash.Error.Forbidden.MustPassStrictCheck
alias Ash.Error.Framework.AssumptionFailed
alias Ash.Error.Invalid.{DuplicatedPath, ImpossiblePath}
require Ash.Query
@ -288,8 +287,8 @@ defmodule Ash.Engine.Request do
end
case try_resolve_local(request, key, true) do
{:skipped, _, _, _} ->
{:error, AssumptionFailed.exception(message: "Skipped fetching data"), request}
{:skipped, new_request, notifications, waiting_for} ->
{:waiting, new_request, notifications, waiting_for}
{:ok, request, notifications, []} ->
if key == :changeset do
@ -407,7 +406,7 @@ defmodule Ash.Engine.Request do
resource
|> Ash.Resource.Info.authorizers()
|> Enum.all?(fn authorizer ->
authorizer_state(request, authorizer) == :authorizer
authorizer_state(request, authorizer) == :authorized
end)
%{request | authorized?: authorized?}
@ -503,10 +502,21 @@ defmodule Ash.Engine.Request do
[] ->
case strict_check_authorizer(authorizer, request) do
:authorized ->
{:ok, set_authorizer_state(request, authorizer, :authorized)}
{:ok, set_authorizer_state(request, authorizer, :authorized), notifications, []}
{:filter, filter} ->
apply_filter(request, authorizer, filter, true)
request
|> apply_filter(authorizer, filter, true)
|> case do
{:ok, request} ->
{:ok, request, notifications, []}
{:ok, request, new_notifications, deps} ->
{:ok, request, new_notifications ++ notifications, deps}
other ->
other
end
{:filter_and_continue, _, _} when strict_check_only? ->
{:error, MustPassStrictCheck.exception(resource: request.resource)}
@ -515,12 +525,22 @@ defmodule Ash.Engine.Request do
request
|> set_authorizer_state(authorizer, new_authorizer_state)
|> apply_filter(authorizer, filter)
|> case do
{:ok, request} ->
{:ok, request, notifications, []}
{:ok, request, new_notifications, deps} ->
{:ok, request, new_notifications ++ notifications, deps}
other ->
other
end
{:continue, _} when strict_check_only? ->
{:error, MustPassStrictCheck.exception(resource: request.resource)}
{:continue, authorizer_state} ->
{:ok, set_authorizer_state(request, authorizer, authorizer_state)}
{:ok, set_authorizer_state(request, authorizer, authorizer_state), notifications, []}
{:error, error} ->
{:error, error}
@ -547,14 +567,19 @@ defmodule Ash.Engine.Request do
defp apply_filter(request, authorizer, filter, resolve_data? \\ false)
defp apply_filter(%{action: %{type: :read}} = request, authorizer, filter, resolve_data?) do
defp apply_filter(
%{action: %{type: :read}} = request,
authorizer,
filter,
resolve_data?
) do
request =
request
|> Map.update!(:query, &Ash.Query.filter(&1, ^filter))
|> Map.update(
:authorization_filter,
filter,
&add_to_or_parse(&1, filter, request.resource)
&add_to_or_parse(&1, filter, request.resource, request.query)
)
|> set_authorizer_state(authorizer, :authorized)
@ -581,11 +606,17 @@ defmodule Ash.Engine.Request do
end
end
defp add_to_or_parse(existing_authorization_filter, filter, resource) do
defp add_to_or_parse(existing_authorization_filter, filter, resource, query) do
if existing_authorization_filter do
Ash.Filter.add_to_filter(existing_authorization_filter, filter)
Ash.Filter.add_to_filter(
existing_authorization_filter,
filter,
query.aggregates,
query.calculations,
query.context
)
else
Ash.Filter.parse!(resource, filter)
Ash.Filter.parse!(resource, filter, query.aggregates, query.calculations, query.context)
end
end
@ -776,7 +807,14 @@ defmodule Ash.Engine.Request do
authorized? = Enum.all?(Map.values(request.authorizer_state), &(&1 == :authorized))
# Don't fetch honor requests for data until the request is authorized
if field in [:data, :query, :changeset, :authorized?, :data_layer_query] and not authorized? and
if field in [
:data,
:query,
:changeset,
:authorized?,
:data_layer_query,
:authorization_filter
] and not authorized? and
not internal? do
try_resolve_dependencies_of(request, field, internal?)
else
@ -826,6 +864,38 @@ defmodule Ash.Engine.Request do
log(request, fn -> "resolving #{field}" end)
case resolver.(resolver_context) do
{:requests, requests} ->
new_deps =
Enum.flat_map(requests, fn
{request, key} ->
[request.path ++ [key]]
_request ->
[]
end)
new_unresolved =
Map.update!(
unresolved,
:deps,
&(&1 ++ new_deps)
)
new_request = Map.put(request, field, new_unresolved)
new_requests =
Enum.map(requests, fn
{request, _} ->
request
request ->
request
end)
{:skipped, new_request,
notifications ++
[{:requests, new_requests}], new_deps}
{:ok, value, instructions} ->
set_data_notifications =
Enum.map(Map.get(instructions, :extra_data, %{}), fn {key, value} ->
@ -834,12 +904,28 @@ defmodule Ash.Engine.Request do
resource_notifications = Map.get(instructions, :notifications, [])
extra_requests = Map.get(instructions, :requests, [])
request_notifications =
case extra_requests do
[] ->
[]
nil ->
[]
requests ->
[{:requests, requests}]
end
handle_successful_resolve(
field,
value,
request,
new_request,
notifications ++ resource_notifications ++ set_data_notifications,
notifications ++
resource_notifications ++
set_data_notifications ++ request_notifications,
internal?
)
@ -874,7 +960,7 @@ defmodule Ash.Engine.Request do
{new_request, notifications}
else
{request, []}
{request, Enum.filter(notifications, &match?({:requests, _}, &1))}
end
new_request = Map.put(new_request, field, value)
@ -939,6 +1025,7 @@ defmodule Ash.Engine.Request do
resolver_context
end
end)
|> Map.put(:verbose?, request.verbose?)
end
defp local_dep?(request, dep) do
@ -990,7 +1077,11 @@ defmodule Ash.Engine.Request do
keys = Authorizer.strict_check_context(authorizer, authorizer_state)
Authorizer.strict_check(authorizer, authorizer_state, Map.take(request, keys))
Authorizer.strict_check(
authorizer,
authorizer_state,
Map.take(request, keys)
)
end
defp check_authorizer(authorizer, request) do

View file

@ -200,6 +200,13 @@ defmodule Ash.Engine.RequestHandler do
send(state.runner_pid, {state.runner_ref, {:data, [key], value}})
state
{:requests, new_requests}, state ->
unless Enum.empty?(new_requests) do
GenServer.cast(state.engine_pid, {:new_requests, new_requests})
end
state
{:update_changeset, changeset}, state ->
send(state.runner_pid, {state.runner_ref, {:update_changeset, changeset}})
state

View file

@ -4,7 +4,9 @@ defmodule Ash.Engine.Runner do
:engine_pid,
:ref,
notified_of_complete?: false,
local_failed?: false,
requests: [],
completed: [],
errors: [],
changeset: %{},
data: %{},
@ -34,6 +36,7 @@ defmodule Ash.Engine.Runner do
engine_pid: engine_pid,
pid_info: pid_info,
ref: ref,
data: %{verbose?: verbose?},
changeset: changeset,
resource_notifications: []
}
@ -50,43 +53,43 @@ defmodule Ash.Engine.Runner do
end
def run_to_completion(state) do
# This allows for publishing any dependencies
if Enum.all?(state.requests, &(&1.state in [:complete, :error])) do
# This allows for publishing any dependencies
new_state = run_iteration(state)
state = run_iteration(state)
if new_state.engine_pid do
new_state =
if new_state.notified_of_complete? do
new_state
else
log(new_state, fn -> "notifying engine of local request completion" end)
GenServer.cast(new_state.engine_pid, :local_requests_complete)
%{new_state | notified_of_complete?: true}
end
wait_for_engine(new_state, true)
if Enum.all?(state.requests, &(&1.state in [:complete, :error])) do
if state.engine_pid do
wait_for_engine(state, true)
else
log(state, fn -> "Synchronous engine complete." end)
state
end
else
log(state, fn -> "Synchronous engine complete." end)
new_state
do_run_to_completion(state)
end
else
case run_iteration(state) do
new_state when new_state == state ->
if new_state.engine_pid do
wait_for_engine(new_state, false)
else
if new_state.errors == [] do
log(state, fn -> "Synchronous engine stuck:\n\n#{stuck_report(state)}" end)
GenServer.cast(state.engine_pid, :log_stuck_report)
add_error(new_state, :__engine__, SynchronousEngineStuck.exception([]))
else
new_state
end
end
do_run_to_completion(state)
end
end
new_state ->
run_to_completion(new_state)
end
defp do_run_to_completion(state) do
case run_iteration(state) do
new_state when new_state == state ->
if new_state.engine_pid do
wait_for_engine(new_state, false)
else
if new_state.errors == [] do
log(state, fn -> "Synchronous engine stuck:\n\n#{stuck_report(state)}" end)
GenServer.cast(state.engine_pid, :log_stuck_report)
add_error(new_state, :__engine__, SynchronousEngineStuck.exception([]))
else
new_state
end
end
new_state ->
run_to_completion(new_state)
end
end
@ -125,6 +128,10 @@ defmodule Ash.Engine.Runner do
do_wait_for_engine(state, complete?)
end
defp do_wait_for_engine(%{local_failed?: true} = state, _complete?) do
state
end
defp do_wait_for_engine(%{engine_pid: engine_pid, ref: ref} = state, complete?) do
receive do
{^ref, {:update_changeset, changeset}} ->
@ -136,8 +143,9 @@ defmodule Ash.Engine.Runner do
new_state =
case Request.wont_receive(request, path, field) do
{:stop, :dependency_failed, new_request} ->
notify_error({:dependency_failed, path}, state)
replace_request(state, %{new_request | state: :error})
state
|> notify_error({:dependency_failed, path})
|> replace_request(%{new_request | state: :error})
end
run_to_completion(new_state)
@ -205,6 +213,11 @@ defmodule Ash.Engine.Runner do
|> add_data(path, data)
|> run_to_completion()
{^ref, {:requests, requests}} ->
state
|> handle_requests(requests)
|> run_to_completion()
{:DOWN, _, _, ^engine_pid,
{:shutdown, %{errored_requests: [], runner_ref: ^ref} = engine_state}} ->
log(state, fn -> "Engine complete" end)
@ -264,7 +277,23 @@ defmodule Ash.Engine.Runner do
{new_state, notifications ++ new_notifications, new_dependencies ++ dependencies}
end)
store_dependencies(new_state, dependencies, notifications)
new_state
|> store_dependencies(dependencies, notifications)
|> notify_engine_complete()
end
defp notify_engine_complete(state) do
newly_completed =
state.requests
|> Enum.filter(&(&1.state in [:complete, :error]))
|> Enum.reject(&(&1.path in state.completed))
|> Enum.map(& &1.path)
Enum.each(newly_completed, fn completed_path ->
GenServer.cast(state.engine_pid, {:local_request_complete, completed_path})
end)
%{state | completed: state.completed ++ newly_completed}
end
defp store_dependencies(state, dependencies, notifications \\ []) do
@ -332,10 +361,9 @@ defmodule Ash.Engine.Runner do
{new_state, notifications ++ new_notifications, new_dependencies ++ dependencies}
{:error, error, new_request} ->
notify_error(error, state)
new_state =
state
|> notify_error(error)
|> replace_request(%{new_request | state: :error, error?: true})
|> add_error(new_request.path, error)
|> replace_request(%{new_request | state: :error, error?: true})
@ -344,8 +372,10 @@ defmodule Ash.Engine.Runner do
end
end
defp notify_error(error, state) do
defp notify_error(state, error) do
GenServer.cast(state.engine_pid, {:local_requests_failed, error})
%{state | local_failed?: true}
end
defp notify(state, notifications) do
@ -355,6 +385,13 @@ defmodule Ash.Engine.Runner do
|> List.wrap()
|> Enum.uniq()
|> Enum.reduce(state, fn
{:requests, requests}, state ->
unless Enum.empty?(requests) do
GenServer.cast(state.engine_pid, {:new_requests, requests})
end
state
{:set_extra_data, key, value}, state ->
%{state | data: Map.put(state.data, key, value)}
@ -381,6 +418,15 @@ defmodule Ash.Engine.Runner do
end)
end
defp handle_requests(state, []) do
state
end
defp handle_requests(state, requests) do
# TODO: At some point we might run these requests async, e.g send some to the engine
%{state | requests: state.requests ++ requests, notified_of_complete?: false}
end
defp replace_request(state, request) do
%{
state
@ -415,20 +461,18 @@ defmodule Ash.Engine.Runner do
{new_state, notifications, new_dependencies}
{:error, error, notifications, new_request} ->
notify_error(error, state)
new_state =
state
|> notify_error(error)
|> add_error(new_request.path, error)
|> replace_request(%{new_request | state: :error})
{new_state, notifications, []}
{:error, error, new_request} ->
notify_error(error, state)
new_state =
state
|> notify_error(error)
|> add_error(new_request.path, error)
|> replace_request(%{new_request | state: :error})

View file

@ -178,7 +178,7 @@ defmodule Ash.Error do
end
end
def error_messages(errors, custom_message \\ nil, stacktraces? \\ false) do
def error_messages(errors, custom_message, stacktraces?) do
errors = Enum.map(errors, &to_ash_error/1)
generic_message =

View file

@ -3,7 +3,7 @@ defmodule Ash.Error.Forbidden do
use Ash.Error.Exception
def_ash_error([:errors, :stacktraces?], class: :forbidden)
def_ash_error([:errors, stacktraces?: true], class: :forbidden)
defimpl Ash.ErrorKind do
def id(_), do: Ash.UUID.generate()

View file

@ -2,7 +2,7 @@ defmodule Ash.Error.Framework do
@moduledoc "Used when an unknown/generic framework error occurs"
use Ash.Error.Exception
def_ash_error([:errors, :stacktraces?], class: :framework)
def_ash_error([:errors, stacktraces?: true], class: :framework)
defimpl Ash.ErrorKind do
def id(_), do: Ash.UUID.generate()

View file

@ -2,7 +2,7 @@ defmodule Ash.Error.Invalid do
@moduledoc "The top level invalid error"
use Ash.Error.Exception
def_ash_error([:errors, :stacktraces?], class: :invalid)
def_ash_error([:errors, stacktraces?: true], class: :invalid)
defimpl Ash.ErrorKind do
def id(_), do: Ash.UUID.generate()

View file

@ -0,0 +1,20 @@
defmodule Ash.Error.Query.CalculationsNotSupported do
@moduledoc "Used when the data_layer does not support calculations, or filtering/sorting them"
use Ash.Error.Exception
def_ash_error([:resource, :feature], class: :invalid)
defimpl Ash.ErrorKind do
def id(_), do: Ash.UUID.generate()
def code(_), do: "calculations_not_supported"
def class(_), do: :invalid
def message(%{resource: resource, feature: feature}) do
"Data layer for #{inspect(resource)} does not support #{feature} calculations"
end
def stacktrace(_), do: nil
end
end

View file

@ -2,7 +2,7 @@ defmodule Ash.Error.Unknown do
@moduledoc "The top level unknown error container"
use Ash.Error.Exception
def_ash_error([:errors, :error, :stacktraces?, :field], class: :unknown)
def_ash_error([:errors, :error, :field, stacktraces?: true], class: :unknown)
defimpl Ash.ErrorKind do
def id(_), do: Ash.UUID.generate()

View file

@ -6,6 +6,7 @@ defmodule Ash.Filter do
alias Ash.Error.Query.{
AggregatesNotSupported,
CalculationsNotSupported,
InvalidFilterValue,
NoSuchAttributeOrRelationship,
NoSuchFilterPredicate,
@ -16,7 +17,7 @@ defmodule Ash.Filter do
alias Ash.Error.Invalid.InvalidPrimaryKey
alias Ash.Query.Function.{Ago, Contains, IsNil}
alias Ash.Query.Function.{Ago, Contains, If, IsNil}
alias Ash.Query.Operator.{
Eq,
@ -30,12 +31,13 @@ defmodule Ash.Filter do
}
alias Ash.Query.{BooleanExpression, Call, Not, Ref}
alias Ash.Query.{Aggregate, Function, Operator}
alias Ash.Query.{Aggregate, Calculation, Function, Operator}
@functions [
Ago,
Contains,
IsNil
IsNil,
If
]
@operators [
@ -137,8 +139,9 @@ defmodule Ash.Filter do
Maps are also accepted, as are maps with string keys. Technically, a list of `[{"string_key", value}]` would also work.
If you are using a map with string keys, it is likely that you are parsing input. It is important to note that, before
passing a filter supplied from an external source directly to `Ash.Query.filter/2`, you should first call `Ash.Filter.parse_input/2`
(or `Ash.Filter.parse_input/3` if your query has aggregates in it). This ensures that the filter only uses public attributes,
relationships and aggregates.
(or `Ash.Filter.parse_input/4` if your query has aggregates/calculations in it). This ensures that the filter only uses public attributes,
relationships, aggregates and calculations. You may additionally wish to pass in the query context, in the case that you have calculations
that use the provided context.
```
"""
@ -177,13 +180,21 @@ defmodule Ash.Filter do
See `parse/2` for more
"""
def parse_input(resource, statement, aggregates \\ %{}) do
def parse_input(
resource,
statement,
aggregates \\ %{},
calculations \\ %{},
context \\ %{}
) do
context = %{
resource: resource,
relationship_path: [],
aggregates: aggregates,
calculations: calculations,
public?: true,
data_layer: Ash.DataLayer.data_layer(resource)
data_layer: Ash.DataLayer.data_layer(resource),
query_context: context
}
with {:ok, expression} <- parse_expression(statement, context) do
@ -196,8 +207,8 @@ defmodule Ash.Filter do
See `parse_input/2` for more
"""
def parse_input!(resource, statement, aggregates \\ %{}) do
case parse_input(resource, statement, aggregates) do
def parse_input!(resource, statement, aggregates \\ %{}, calculations \\ %{}, context \\ %{}) do
case parse_input(resource, statement, aggregates, calculations, context) do
{:ok, filter} ->
filter
@ -211,8 +222,8 @@ defmodule Ash.Filter do
See `parse/2` for more
"""
def parse!(resource, statement, aggregates \\ %{}) do
case parse(resource, statement, aggregates) do
def parse!(resource, statement, aggregates \\ %{}, calculations \\ %{}, context \\ %{}) do
case parse(resource, statement, aggregates, calculations, context) do
{:ok, filter} ->
filter
@ -233,35 +244,37 @@ defmodule Ash.Filter do
be sure to use `parse_input/2` instead! The only difference is that it only accepts
filters over public attributes/relationships.
### Aggregates
### Aggregates and calculations
Since custom aggregates can be added to a query, and aggregates must be explicitly loaded into
Since custom aggregates/calculations can be added to a query, and they must be explicitly loaded into
a query, the filter parser does not parse them by default. If you wish to support parsing filters
over aggregates, provide them as the third argument. The best way to do this is to build a query
with the aggregates added/loaded, and then use the `aggregates` key on the query, e.g
over aggregates/calculations, provide them as the third argument. The best way to do this is to build a query
with them added/loaded, and then use the `aggregates` and `calculations` keys on the query.
### NOTE
A change was made recently that will automatically load any aggregates that are used in a filter.
This function still requires aggregates to be passed in.
A change was made recently that will automatically load any aggregates/calculations that are used in a filter, but
if you are using this function you still need to pass them in.
```elixir
Ash.Filter.parse(MyResource, [id: 1], query.aggregates)
Ash.Filter.parse(MyResource, [id: 1], query.aggregates, query.calculations)
```
"""
def parse(resource, statement, aggregates \\ %{})
def parse(resource, statement, aggregates \\ %{}, calculations \\ %{}, context \\ %{})
def parse(_resource, nil, _aggregates) do
def parse(_resource, nil, _aggregates, _calculations, _context) do
{:ok, nil}
end
def parse(resource, statement, aggregates) do
def parse(resource, statement, aggregates, calculations, context) do
context = %{
resource: resource,
relationship_path: [],
aggregates: aggregates,
calculations: calculations,
public?: false,
data_layer: Ash.DataLayer.data_layer(resource)
data_layer: Ash.DataLayer.data_layer(resource),
query_context: context
}
with {:ok, expression} <- parse_expression(statement, context) do
@ -353,7 +366,7 @@ defmodule Ash.Filter do
end
@doc "Replace any actor value references in a template with the values from a given actor"
def build_filter_from_template(template, actor, args \\ %{}, context \\ %{}) do
def build_filter_from_template(template, actor \\ nil, args \\ %{}, context \\ %{}) do
walk_filter_template(template, fn
{:_actor, :_primary_key} ->
if actor do
@ -524,11 +537,17 @@ defmodule Ash.Filter do
defp get_predicates(%{__predicate__?: true} = predicate, acc), do: [predicate | acc]
def used_aggregates(filter, relationship_path \\ []) do
def used_calculations(
filter,
resource,
relationship_path \\ [],
calculations \\ %{},
aggregates \\ %{}
) do
filter
|> list_refs()
|> Enum.filter(fn
%Ref{attribute: %Aggregate{}, relationship_path: ref_relationship_path} ->
%Ref{attribute: %Calculation{}, relationship_path: ref_relationship_path} ->
(relationship_path in [nil, []] and ref_relationship_path in [nil, []]) ||
relationship_path == ref_relationship_path
@ -536,13 +555,88 @@ defmodule Ash.Filter do
false
end)
|> Enum.map(& &1.attribute)
|> calculations_used_by_calculations(
resource,
relationship_path,
calculations,
aggregates
)
end
defp calculations_used_by_calculations(
used_calculations,
resource,
relationship_path,
calculations,
aggregates
) do
used_calculations
|> Enum.flat_map(fn calculation ->
expression = calculation.module.expression(calculation.opts, calculation.context)
case Ash.Filter.hydrate_refs(expression, %{
resource: resource,
aggregates: aggregates,
calculations: calculations,
public?: false
}) do
{:ok, expression} ->
with_recursive_used =
calculations_used_by_calculations(
used_calculations(
expression,
resource,
relationship_path,
calculations,
aggregates
),
resource,
relationship_path,
calculations,
aggregates
)
[calculation | with_recursive_used]
_ ->
[calculation]
end
end)
end
def used_aggregates(filter, relationship_path \\ [], return_refs? \\ false) do
refs =
filter
|> list_refs()
|> Enum.filter(fn
%Ref{attribute: %Aggregate{}, relationship_path: ref_relationship_path} ->
relationship_path == :all ||
(relationship_path in [nil, []] and ref_relationship_path in [nil, []]) ||
relationship_path == ref_relationship_path
_ ->
false
end)
if return_refs? do
refs
else
Enum.map(refs, & &1.attribute)
end
end
def put_at_path(value, []), do: value
def put_at_path(value, [key | rest]), do: [{key, put_at_path(value, rest)}]
def add_to_filter!(base, addition, op \\ :and, aggregates \\ %{}) do
case add_to_filter(base, addition, op, aggregates) do
def add_to_filter!(
base,
addition,
op \\ :and,
aggregates \\ %{},
calculations \\ %{},
context \\ %{}
) do
case add_to_filter(base, addition, op, aggregates, calculations, context) do
{:ok, value} ->
value
@ -551,14 +645,23 @@ defmodule Ash.Filter do
end
end
def add_to_filter(base, addition, op \\ :and, aggregates \\ %{})
def add_to_filter(
base,
addition,
op \\ :and,
aggregates \\ %{},
calculations \\ %{},
context \\ %{}
)
def add_to_filter(nil, %__MODULE__{} = addition, _, _), do: {:ok, addition}
def add_to_filter(nil, %__MODULE__{} = addition, _, _, _, _), do: {:ok, addition}
def add_to_filter(
%__MODULE__{} = base,
%__MODULE__{} = addition,
op,
_,
_,
_
) do
{:ok,
@ -568,9 +671,9 @@ defmodule Ash.Filter do
}}
end
def add_to_filter(%__MODULE__{} = base, statement, op, aggregates) do
case parse(base.resource, statement, aggregates) do
{:ok, filter} -> add_to_filter(base, filter, op, aggregates)
def add_to_filter(%__MODULE__{} = base, statement, op, aggregates, calculations, context) do
case parse(base.resource, statement, aggregates, calculations, context) do
{:ok, filter} -> add_to_filter(base, filter, op, aggregates, calculations)
{:error, error} -> {:error, error}
end
end
@ -701,6 +804,38 @@ defmodule Ash.Filter do
end
end
def update_aggregates(%__MODULE__{expression: expression} = filter, mapper) do
%{filter | expression: update_aggregates(expression, mapper)}
end
def update_aggregates(expression, mapper) do
case expression do
%Not{expression: expression} = not_expr ->
%{not_expr | expression: update_aggregates(expression, mapper)}
%BooleanExpression{left: left, right: right} = expression ->
%{
expression
| left: update_aggregates(left, mapper),
right: update_aggregates(right, mapper)
}
%{__operator__?: true, left: left, right: right} = op ->
left = update_aggregates(left, mapper)
right = update_aggregates(right, mapper)
%{op | left: left, right: right}
%{__function__?: true, arguments: args} = func ->
%{func | arguments: Enum.map(args, &update_aggregates(&1, mapper))}
%Ref{attribute: %Aggregate{} = agg} = ref ->
%{ref | attribute: mapper.(agg, ref)}
other ->
other
end
end
def run_other_data_layer_filters(api, resource, %{expression: expression} = filter) do
case do_run_other_data_layer_filters(expression, api, resource) do
{:ok, new_expression} -> {:ok, %{filter | expression: new_expression}}
@ -890,6 +1025,32 @@ defmodule Ash.Filter do
defp scope_refs(other, _), do: other
def prefix_refs(%BooleanExpression{left: left, right: right} = expr, path) do
%{expr | left: prefix_refs(left, path), right: prefix_refs(right, path)}
end
def prefix_refs(%Not{expression: expression} = expr, path) do
%{expr | expression: prefix_refs(expression, path)}
end
def prefix_refs(%{__predicate__?: _, left: left, right: right} = pred, path) do
%{pred | left: prefix_refs(left, path), right: prefix_refs(right, path)}
end
def prefix_refs(%{__predicate__?: _, argsuments: arguments} = pred, path) do
%{pred | args: Enum.map(arguments, &prefix_refs(&1, path))}
end
def prefix_refs(%Ref{relationship_path: ref_path} = ref, path) do
if List.starts_with?(ref_path, path) do
%{ref | relationship_path: path ++ ref_path}
else
ref
end
end
def prefix_refs(other, _), do: other
defp fetch_related_data(
resource,
path,
@ -1299,6 +1460,12 @@ defmodule Ash.Filter do
defp aggregate(%{public?: false, resource: resource}, aggregate),
do: Ash.Resource.Info.aggregate(resource, aggregate)
defp calculation(%{public?: true, resource: resource}, calculation),
do: Ash.Resource.Info.public_calculation(resource, calculation)
defp calculation(%{public?: false, resource: resource}, calculation),
do: Ash.Resource.Info.calculation(resource, calculation)
defp relationship(%{public?: true, resource: resource}, relationship) do
Ash.Resource.Info.public_relationship(resource, relationship)
end
@ -1405,6 +1572,8 @@ defmodule Ash.Filter do
relationship_path: ref.relationship_path,
resource: related,
aggregates: context.aggregates,
calculations: context.calculations,
query_context: context.query_context,
public?: context.public?
}
@ -1444,6 +1613,11 @@ defmodule Ash.Filter do
[key, to_string(key)]
end)
calculations =
Enum.flat_map(context.calculations, fn {key, _} ->
[key, to_string(key)]
end)
cond do
function_module = get_function(field, Ash.DataLayer.data_layer_functions(context.resource)) ->
with {:ok, args} <-
@ -1476,6 +1650,10 @@ defmodule Ash.Filter do
context
|> Map.update!(:relationship_path, fn path -> path ++ [rel.name] end)
|> Map.put(:resource, rel.destination)
|> Map.update!(
:query_context,
&Ash.Helpers.deep_merge_maps(&1 || %{}, rel.context || %{})
)
if is_list(nested_statement) || is_map(nested_statement) do
case parse_expression(nested_statement, context) do
@ -1542,6 +1720,18 @@ defmodule Ash.Filter do
{:error, error}
end
field in calculations ->
{module, _} = module_and_opts(Map.get(context.calculations, field).calculation)
field =
if is_binary(field) do
String.to_existing_atom(field)
else
field
end
add_calculation_expression(context, nested_statement, field, module, expression)
field in aggregates ->
field =
if is_binary(field) do
@ -1552,6 +1742,34 @@ defmodule Ash.Filter do
add_aggregate_expression(context, nested_statement, field, expression)
resource_calculation = calculation(context, field) ->
{module, opts} = module_and_opts(resource_calculation.calculation)
with {:ok, args} <-
Ash.Query.validate_calculation_arguments(
resource_calculation,
%{}
),
{:ok, calculation} <-
Calculation.new(
resource_calculation.name,
module,
opts,
resource_calculation.type,
Map.put(args, :context, context.query_context)
) do
case parse_predicates(nested_statement, calculation, context) do
{:ok, nested_statement} ->
{:ok, BooleanExpression.optimized_new(:and, expression, nested_statement)}
{:error, error} ->
{:error, error}
end
else
{:error, error} ->
{:error, error}
end
op_module = get_operator(field) && match?([_, _ | _], nested_statement) ->
with {:ok, [left, right]} <-
hydrate_refs(nested_statement, context),
@ -1687,33 +1905,67 @@ defmodule Ash.Filter do
end
defp resolve_call(%Call{name: name, args: args} = call, context) do
with :ok <- validate_datalayer_supports_nested_expressions(args, context.resource),
{:ok, args} <-
hydrate_refs(args, context),
refs <- list_refs(args),
:ok <- validate_not_crossing_datalayer_boundaries(refs, context.resource, call),
{:func, function_module} when not is_nil(function_module) <-
{:func, get_function(name, Ash.DataLayer.data_layer_functions(context.resource))},
{:ok, function} <-
Function.new(
function_module,
args
) do
if is_boolean(function) do
{:ok, function}
else
if Ash.DataLayer.data_layer_can?(context.resource, {:filter_expr, function}) do
{:ok, function}
else
{:error, "data layer does not support the function #{inspect(function)}"}
end
end
else
{:func, nil} ->
{:error, NoSuchFunction.exception(name: name, resource: context.resource)}
could_be_calculation? = Enum.count(args) == 1 && Keyword.keyword?(Enum.at(args, 0))
other ->
other
resource = Ash.Resource.Info.related(context.resource, call.relationship_path)
case {calculation(%{context | resource: resource}, name), could_be_calculation?} do
{resource_calculation, true} when not is_nil(resource_calculation) ->
{module, opts} = module_and_opts(resource_calculation.calculation)
with {:ok, args} <-
Ash.Query.validate_calculation_arguments(
resource_calculation,
Map.new(Enum.at(args, 0) || [])
),
{:ok, calculation} <-
Calculation.new(
resource_calculation.name,
module,
opts,
resource_calculation.type,
Map.put(args, :context, context.query_context)
) do
{:ok,
%Ref{
attribute: calculation,
relationship_path: context.relationship_path ++ call.relationship_path,
resource: resource
}}
else
{:error, error} ->
{:error, error}
end
_ ->
with :ok <- validate_datalayer_supports_nested_expressions(args, context.resource),
{:ok, args} <-
hydrate_refs(args, context),
refs <- list_refs(args),
:ok <- validate_not_crossing_datalayer_boundaries(refs, context.resource, call),
{:func, function_module} when not is_nil(function_module) <-
{:func, get_function(name, Ash.DataLayer.data_layer_functions(context.resource))},
{:ok, function} <-
Function.new(
function_module,
args
) do
if is_boolean(function) do
{:ok, function}
else
if Ash.DataLayer.data_layer_can?(context.resource, {:filter_expr, function}) do
{:ok, function}
else
{:error, "data layer does not support the function #{inspect(function)}"}
end
end
else
{:func, nil} ->
{:error, NoSuchFunction.exception(name: name, resource: context.resource)}
other ->
other
end
end
end
@ -1732,21 +1984,50 @@ defmodule Ash.Filter do
defp is_expr?(%{__predicate__?: _}), do: true
defp is_expr?(_), do: false
defp hydrate_refs(%Ref{attribute: attribute} = ref, %{aggregates: aggregates} = context)
when is_atom(attribute) do
defp module_and_opts({module, opts}), do: {module, opts}
defp module_and_opts(module), do: {module, []}
def hydrate_refs(
%Ref{attribute: attribute} = ref,
%{aggregates: aggregates, calculations: calculations} = context
)
when is_atom(attribute) do
case related(context, ref.relationship_path) do
nil ->
{:error, "Invalid reference #{inspect(ref)}"}
{:error,
"Invalid reference #{inspect(ref)} at relationship_path #{inspect(ref.relationship_path)}"}
related ->
context = %{context | resource: related}
cond do
Map.has_key?(aggregates, attribute) ->
{:ok, %{ref | attribute: Map.get(aggregates, attribute)}}
{:ok, %{ref | attribute: Map.get(aggregates, attribute), resource: related}}
Map.has_key?(calculations, attribute) ->
{:ok, %{ref | attribute: Map.get(calculations, attribute), resource: related}}
attribute = attribute(context, attribute) ->
{:ok, %{ref | attribute: attribute}}
{:ok, %{ref | attribute: attribute, resource: related}}
resource_calculation = calculation(context, attribute) ->
{module, opts} = module_and_opts(resource_calculation.calculation)
with {:ok, args} <-
Ash.Query.validate_calculation_arguments(resource_calculation, %{}),
{:ok, calculation} <-
Calculation.new(
resource_calculation.name,
module,
opts,
resource_calculation.type,
Map.put(args, :context, context.query_context)
) do
{:ok, %{ref | attribute: calculation, resource: related}}
else
{:error, error} ->
{:error, error}
end
aggregate = aggregate(context, attribute) ->
agg_related = Ash.Resource.Info.related(related, aggregate.relationship_path)
@ -1762,7 +2043,7 @@ defmodule Ash.Filter do
aggregate_query,
aggregate.field
) do
{:ok, %{ref | attribute: query_aggregate}}
{:ok, %{ref | attribute: query_aggregate, resource: related}}
else
%{valid?: false, errors: errors} ->
{:error, errors}
@ -1777,13 +2058,17 @@ defmodule Ash.Filter do
new_ref = %{
ref
| relationship_path: ref.relationship_path ++ [relationship.name],
attribute: Ash.Resource.Info.attribute(relationship.destination, key)
attribute: Ash.Resource.Info.attribute(relationship.destination, key),
resource: relationship.destination
}
{:ok, new_ref}
_ ->
{:error, "Invalid reference #{inspect(ref)}"}
{:error,
"Invalid reference #{inspect(ref)} when hydrating relationship ref for #{
inspect(ref.relationship_path ++ [relationship.name])
}. Require single attribute primary key."}
end
true ->
@ -1792,7 +2077,11 @@ defmodule Ash.Filter do
end
end
defp hydrate_refs(%BooleanExpression{left: left, right: right} = expr, context) do
def hydrate_refs(%Ref{relationship_path: relationship_path, resource: nil} = ref, context) do
{:ok, %{ref | resource: Ash.Resource.Info.related(context.resource, relationship_path)}}
end
def hydrate_refs(%BooleanExpression{left: left, right: right} = expr, context) do
with {:ok, left} <- hydrate_refs(left, context),
{:ok, right} <- hydrate_refs(right, context) do
{:ok, %{expr | left: left, right: right}}
@ -1802,11 +2091,17 @@ defmodule Ash.Filter do
end
end
defp hydrate_refs(%Call{} = call, context) do
def hydrate_refs(%Not{expression: expression} = expr, context) do
with {:ok, expression} <- hydrate_refs(expression, context) do
{:ok, %{expr | expression: expression}}
end
end
def hydrate_refs(%Call{} = call, context) do
resolve_call(call, context)
end
defp hydrate_refs(%{__predicate__?: _, left: left, right: right} = expr, context) do
def hydrate_refs(%{__predicate__?: _, left: left, right: right} = expr, context) do
with {:ok, left} <- hydrate_refs(left, context),
{:ok, right} <- hydrate_refs(right, context) do
{:ok, %{expr | left: left, right: right}}
@ -1816,17 +2111,17 @@ defmodule Ash.Filter do
end
end
defp hydrate_refs(%{__predicate__?: _, arguments: arguments} = expr, context) do
def hydrate_refs(%{__predicate__?: _, arguments: arguments} = expr, context) do
case hydrate_refs(arguments, context) do
{:ok, args} ->
{:ok, %{expr | argumentss: args}}
{:ok, %{expr | arguments: args}}
other ->
other
end
end
defp hydrate_refs(list, context) when is_list(list) do
def hydrate_refs(list, context) when is_list(list) do
list
|> Enum.reduce_while({:ok, []}, fn val, {:ok, acc} ->
case hydrate_refs(val, context) do
@ -1843,7 +2138,7 @@ defmodule Ash.Filter do
end
end
defp hydrate_refs(val, _context) do
def hydrate_refs(val, _context) do
{:ok, val}
end
@ -1861,6 +2156,22 @@ defmodule Ash.Filter do
end
end
defp add_calculation_expression(context, nested_statement, field, module, expression) do
if Ash.DataLayer.data_layer_can?(context.resource, :expression_calculation) &&
:erlang.function_exported(module, :expression, 1) do
case parse_predicates(nested_statement, Map.get(context.calculations, field), context) do
{:ok, nested_statement} ->
{:ok, BooleanExpression.optimized_new(:and, expression, nested_statement)}
{:error, error} ->
{:error, error}
end
else
{:error,
CalculationsNotSupported.exception(resource: context.resource, feature: "filtering")}
end
end
defp validate_data_layers_support_boolean_filters(%BooleanExpression{
op: :or,
left: left,
@ -1916,12 +2227,15 @@ defmodule Ash.Filter do
}
%{__operator__?: true, left: left, right: right} = op ->
left = add_to_ref_path(left, context.relationship_path)
right = add_to_ref_path(right, context.relationship_path)
left = add_to_predicate_path(left, context)
right = add_to_predicate_path(right, context)
%{op | left: left, right: right}
%Ref{} = ref ->
add_to_ref_path(ref, context.relationship_path)
%{__function__?: true, arguments: args} = func ->
%{func | arguments: Enum.map(args, &add_to_ref_path(&1, context.relationship_path))}
%{func | arguments: Enum.map(args, &add_to_predicate_path(&1, context))}
other ->
other

View file

@ -21,11 +21,12 @@ defmodule Ash.Filter.Runtime do
that could only be determined by data layer), it is assumed that they
are not matches.
"""
def filter_matches(_api, [], _filter, _), do: {:ok, []}
def filter_matches(api, records, filter, loaded? \\ false)
def filter_matches(_api, [], _filter, _loaded), do: {:ok, []}
def filter_matches(_api, records, nil), do: {:ok, records}
def filter_matches(_api, records, nil, _loaded), do: {:ok, records}
def filter_matches(api, records, filter) do
def filter_matches(api, records, filter, loaded?) do
filter
|> Ash.Filter.list_refs()
|> Enum.map(& &1.relationship_path)
@ -40,14 +41,17 @@ defmodule Ash.Filter.Runtime do
matches?(nil, record, filter)
end)}
need_to_load ->
need_to_load when not loaded? ->
case api.load(records, need_to_load) do
{:ok, loaded} ->
filter_matches(api, loaded, filter)
filter_matches(api, loaded, filter, true)
other ->
other
end
_need_to_load when loaded? ->
{:ok, []}
end
end
@ -135,7 +139,7 @@ defmodule Ash.Filter.Runtime do
end)
end
defp do_match(record, expression) do
def do_match(record, expression) do
case expression do
%Ash.Filter{expression: expression} ->
do_match(record, expression)
@ -159,7 +163,7 @@ defmodule Ash.Filter.Runtime do
:unknown
_ ->
{:ok, false}
{:ok, nil}
end
%func{__function__?: true, arguments: arguments} = function ->
@ -174,7 +178,7 @@ defmodule Ash.Filter.Runtime do
:unknown
_ ->
{:ok, false}
{:ok, nil}
end
%Not{expression: expression} ->
@ -240,7 +244,7 @@ defmodule Ash.Filter.Runtime do
:unknown
_ ->
{:ok, false}
{:ok, nil}
end
end
@ -256,7 +260,7 @@ defmodule Ash.Filter.Runtime do
:unknown
_ ->
{:ok, false}
{:ok, nil}
end
end
@ -299,6 +303,9 @@ defmodule Ash.Filter.Runtime do
{:ok, false} ->
{:ok, false}
{:ok, nil} ->
{:ok, false}
:unknown ->
:unknown
end
@ -312,6 +319,9 @@ defmodule Ash.Filter.Runtime do
{:ok, false} ->
do_match(record, right)
{:ok, nil} ->
do_match(record, right)
:unknown ->
:unknown
end

View file

@ -121,11 +121,17 @@ defmodule Ash.Query.Aggregate do
def kind_to_type(:list, field_type), do: {:ok, {:array, 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
def requests(initial_query, can_be_in_query?, authorizing?, calculations_in_query) do
initial_query.aggregates
|> Map.values()
|> Enum.group_by(&{&1.resource, &1.relationship_path})
|> Enum.reduce({[], [], []}, fn {{aggregate_resource, relationship_path}, aggregates},
|> Enum.map(&{{&1.resource, &1.relationship_path, []}, &1})
|> Enum.concat(aggregates_from_filter(initial_query))
|> Enum.group_by(&elem(&1, 0))
|> Enum.map(fn {key, value} ->
{key, Enum.uniq_by(Enum.map(value, &elem(&1, 1)), & &1.name)}
end)
|> Enum.reduce({[], [], []}, fn {{aggregate_resource, relationship_path, ref_path},
aggregates},
{auth_requests, value_requests, aggregates_in_query} ->
related = Ash.Resource.Info.related(aggregate_resource, relationship_path)
@ -138,17 +144,25 @@ defmodule Ash.Query.Aggregate do
{in_query?, reverse_relationship} =
case Load.reverse_relationship_path(relationship, tl(relationship_path)) do
:error ->
{can_be_in_query?, nil}
{ref_path == [] && can_be_in_query?, nil}
{:ok, reverse_relationship} ->
{can_be_in_query? &&
any_aggregate_matching_path_used_in_query?(initial_query, relationship_path),
reverse_relationship}
{ref_path == [] && can_be_in_query? &&
any_aggregate_matching_path_used_in_query?(
initial_query,
relationship_path,
calculations_in_query
), reverse_relationship}
end
auth_request =
if authorizing? do
auth_request(related, initial_query, reverse_relationship, relationship_path)
auth_request(
related,
initial_query,
reverse_relationship,
ref_path ++ relationship_path
)
else
nil
end
@ -163,22 +177,62 @@ defmodule Ash.Query.Aggregate do
if in_query? do
{new_auth_requests, value_requests, aggregates_in_query ++ aggregates}
else
request =
value_request(
initial_query,
related,
reverse_relationship,
relationship_path,
aggregates,
auth_request,
aggregate_resource
)
if ref_path == [] do
request =
value_request(
initial_query,
related,
reverse_relationship,
relationship_path,
aggregates,
auth_request,
aggregate_resource
)
{new_auth_requests, [request | value_requests], aggregates_in_query}
{new_auth_requests, [request | value_requests], aggregates_in_query}
else
{new_auth_requests, value_requests, aggregates_in_query}
end
end
end)
end
defp aggregates_from_filter(query) do
aggs =
query.filter
|> Ash.Filter.used_aggregates(:all, true)
|> Enum.reject(&(&1.relationship_path == []))
|> Enum.map(fn ref ->
{{ref.resource, ref.attribute.relationship_path, ref.attribute.relationship_path},
ref.attribute}
end)
calculations =
query.filter
|> Ash.Filter.used_calculations(query.resource)
|> Enum.flat_map(fn calculation ->
expression = calculation.module.expression(calculation.opts, calculation.context)
case Ash.Filter.hydrate_refs(expression, %{
resource: query.resource,
aggregates: query.aggregates,
calculations: query.calculations,
public?: false
}) do
{:ok, expression} ->
Ash.Filter.used_aggregates(expression)
_ ->
[]
end
end)
|> Enum.map(fn aggregate ->
{{query.resource, aggregate.relationship_path, []}, aggregate}
end)
Enum.uniq_by(aggs ++ calculations, &elem(&1, 1).name)
end
defp auth_request(related, initial_query, reverse_relationship, relationship_path) do
Request.new(
resource: related,
@ -250,13 +304,16 @@ defmodule Ash.Query.Aggregate do
aggregates =
if auth_request do
case get_in(data, [auth_request.path ++ [:authorization_filter]]) do
case get_in(data, auth_request.path ++ [:authorization_filter]) do
nil ->
aggregates
filter ->
Enum.map(aggregates, fn aggregate ->
%{aggregate | query: Ash.Query.filter(aggregate.query, ^filter)}
%{
aggregate
| query: Ash.Query.filter(aggregate.query, ^filter)
}
end)
end
else
@ -345,7 +402,7 @@ defmodule Ash.Query.Aggregate do
)
end
defp any_aggregate_matching_path_used_in_query?(query, relationship_path) do
defp any_aggregate_matching_path_used_in_query?(query, relationship_path, calculations_in_query) do
filter_aggregates =
if query.filter do
Ash.Filter.used_aggregates(query.filter)
@ -353,7 +410,37 @@ defmodule Ash.Query.Aggregate do
[]
end
if Enum.any?(filter_aggregates, &(&1.relationship_path == relationship_path)) do
used_calculations =
Ash.Filter.used_calculations(
query.filter,
query.resource
) ++ calculations_in_query
calculation_aggregates =
used_calculations
|> Enum.filter(&:erlang.function_exported(&1.module, :expression, 2))
|> Enum.flat_map(fn calculation ->
case Ash.Filter.hydrate_refs(
calculation.module.expression(calculation.opts, calculation.context),
%{
resource: query.resource,
aggregates: query.aggregates,
calculations: query.calculations,
public?: false
}
) do
{:ok, hydrated} ->
Ash.Filter.used_aggregates(hydrated)
_ ->
[]
end
end)
if Enum.any?(
filter_aggregates ++ calculation_aggregates,
&(&1.relationship_path == relationship_path)
) do
true
else
sort_aggregates =
@ -367,7 +454,42 @@ defmodule Ash.Query.Aggregate do
end
end)
Enum.any?(sort_aggregates, &(&1.relationship_path == relationship_path))
sort_calculations =
Enum.flat_map(query.sort, fn {field, _} ->
case Map.fetch(query.calculations, field) do
:error ->
[]
{:ok, calc} ->
[calc]
end
end)
sort_calc_aggregates =
sort_calculations
|> Enum.filter(&:erlang.function_exported(&1.module, :expression, 2))
|> Enum.flat_map(fn calculation ->
case Ash.Filter.hydrate_refs(
calculation.module.expression(calculation.opts, calculation.context),
%{
resource: query.resource,
aggregates: query.aggregates,
calculations: query.calculations,
public?: false
}
) do
{:ok, hydrated} ->
Ash.Filter.used_aggregates(hydrated)
_ ->
[]
end
end)
Enum.any?(
sort_aggregates ++ sort_calc_aggregates,
&(&1.relationship_path == relationship_path)
)
end
end

View file

@ -1,17 +1,18 @@
defmodule Ash.Query.Calculation do
@moduledoc "Represents a calculated attribute requested on a query"
defstruct [:name, :module, :opts, :load, context: %{}]
defstruct [:name, :module, :opts, :load, :type, context: %{}, select: [], sequence: 0]
@type t :: %__MODULE__{}
def new(name, module, opts, context \\ %{}) do
def new(name, module, opts, type, context \\ %{}) do
case module.init(opts) do
{:ok, opts} ->
{:ok,
%__MODULE__{
name: name,
module: module,
type: type,
opts: opts,
context: context
}}

View file

@ -1,7 +1,7 @@
defmodule Ash.Query.Call do
@moduledoc "Represents a function call/AST node in an Ash query expression"
defstruct [:name, :args, operator?: false]
defstruct [:name, :args, relationship_path: [], operator?: false]
defimpl Inspect do
import Inspect.Algebra
@ -16,7 +16,15 @@ defmodule Ash.Query.Call do
to_doc(Enum.at(call.args, 1), inspect_opts)
])
else
prefix =
if call.relationship_path == [] do
""
else
Enum.map_join(call.relationship_path, ".", &to_string/1) <> "."
end
concat([
prefix,
to_string(call.name),
container_doc("(", call.args, ")", inspect_opts, &to_doc/2, separator: ", ")
])

View file

@ -0,0 +1,16 @@
defmodule Ash.Query.Function.If do
@moduledoc """
If predicate is truthy, then the second argument is returned, otherwise the third.
"""
use Ash.Query.Function, name: :if
def args, do: [[:boolean, :any, :any]]
def evaluate(%{arguments: [condition, when_true, when_false]}) do
if condition do
{:known, when_true}
else
{:known, when_false}
end
end
end

View file

@ -11,6 +11,9 @@ defmodule Ash.Query.Operator.Basic do
],
div: [
symbol: :/
],
concat: [
symbol: :<>
]
]
@ -22,7 +25,7 @@ defmodule Ash.Query.Operator.Basic do
Module.create(
mod,
quote do
quote generated: true do
@moduledoc """
left #{unquote(opts[:symbol])} right
"""
@ -34,6 +37,20 @@ defmodule Ash.Query.Operator.Basic do
types: [:same, :any]
def evaluate(%{left: left, right: right}) do
if is_nil(left) || is_nil(right) do
nil
else
# delegate to function to avoid dialyzer warning
# that this can only ever be one value (for each module we define)
do_evaluate(unquote(opts[:symbol]), left, right)
end
end
defp do_evaluate(:<>, left, right) do
{:known, left <> right}
end
defp do_evaluate(op, left, right) do
{:known, apply(Kernel, unquote(opts[:symbol]), [left, right])}
end
end,

View file

@ -197,7 +197,7 @@ defmodule Ash.Query do
filter ->
filter =
resource
|> Ash.Filter.parse!(filter)
|> Ash.Filter.parse!(filter, query.aggregates, query.calculations, query.context)
|> Ash.Filter.embed_predicates()
do_filter(query, filter)
@ -250,6 +250,7 @@ defmodule Ash.Query do
query = Map.put(query, :action, action.name)
query
|> set_actor(opts)
|> Ash.Query.set_tenant(opts[:tenant] || query.tenant)
|> Map.put(:action, action)
|> Map.put(:__validated_for_action__, action_name)
@ -262,6 +263,14 @@ defmodule Ash.Query do
end
end
defp set_actor(query, opts) do
if Keyword.has_key?(opts, :actor) do
put_context(query, :private, %{actor: opts[:actor]})
else
query
end
end
defp require_arguments(query, action) do
query
|> set_argument_defaults(action)
@ -406,10 +415,47 @@ defmodule Ash.Query do
value
end
defp do_expr({{:., _, [_, _]} = left, _, _}, escape?) do
defp do_expr({{:., _, [_, _]} = left, _, []}, escape?) do
do_expr(left, escape?)
end
defp do_expr({{:., _, [_, _]} = left, _, args}, escape?) do
args = Enum.map(args, &do_expr(&1, false))
case do_expr(left, escape?) do
{:%{}, [], parts} = other when is_list(parts) ->
if Enum.any?(parts, &(&1 == {:__struct__, Ash.Query.Ref})) do
ref = Map.new(parts)
soft_escape(
%Ash.Query.Call{
name: ref.attribute,
relationship_path: ref.relationship_path,
args: args,
operator?: false
},
escape?
)
else
other
end
%Ash.Query.Ref{} = ref ->
soft_escape(
%Ash.Query.Call{
name: ref.attribute,
relationship_path: ref.relationship_path,
args: args,
operator?: false
},
escape?
)
other ->
other
end
end
defp do_expr({:ref, _, [field, path]}, escape?) do
ref =
case do_expr(path, false) do
@ -562,6 +608,19 @@ defmodule Ash.Query do
end
end
def ensure_selected(query, fields) do
if query.select do
Ash.Query.select(query, List.wrap(fields))
else
to_select =
query.resource
|> Ash.Resource.Info.attributes()
|> Enum.map(& &1.name)
Ash.Query.select(query, to_select)
end
end
@doc """
Ensure the the specified attributes are `nil` in the query results.
"""
@ -638,24 +697,33 @@ defmodule Ash.Query do
resource_calculation = Ash.Resource.Info.calculation(query.resource, field) ->
{module, opts} = module_and_opts(resource_calculation.calculation)
with {:ok, args} <- validate_arguments(resource_calculation, rest),
with {:ok, args} <- validate_calculation_arguments(resource_calculation, rest),
{:ok, calculation} <-
Calculation.new(
resource_calculation.name,
module,
opts,
args
resource_calculation.type,
Map.put(args, :context, query.context)
) do
calculation = %{calculation | load: field}
fields_to_select =
resource_calculation.select
|> Kernel.||([])
|> Enum.concat(module.select(query, opts) || [])
|> Enum.concat(module.select(query, opts, calculation.context) || [])
calculation = %{calculation | load: field, select: fields_to_select}
query =
query
|> module.load(
opts,
calculation.context
|> Map.put(:context, query.context)
)
query
|> Ash.Query.load(resource_calculation.load || [])
|> Map.update!(:calculations, &Map.put(&1, field, calculation))
|> maybe_select(fields_to_select)
end
true ->
@ -721,19 +789,34 @@ defmodule Ash.Query do
module -> {module, []}
end
with {:ok, args} <- validate_arguments(resource_calculation, %{}),
with {:ok, args} <- validate_calculation_arguments(resource_calculation, %{}),
{:ok, calculation} <-
Calculation.new(resource_calculation.name, module, opts, args) do
Calculation.new(
resource_calculation.name,
module,
opts,
resource_calculation.type,
Map.put(args, :context, query.context)
) do
calculation = %{calculation | load: field}
fields_to_select =
resource_calculation.select
|> Kernel.||([])
|> Enum.concat(module.select(query, opts) || [])
|> Enum.concat(module.select(query, opts, calculation.context) || [])
query =
query
|> module.load(
opts,
calculation.context
|> Map.put(:context, query.context)
)
|> Ash.Query.load(resource_calculation.load || [])
query
|> Map.update!(:calculations, &Map.put(&1, field, calculation))
|> maybe_select(fields_to_select)
|> ensure_selected(fields_to_select)
else
{:error, error} ->
add_error(query, :load, error)
@ -744,20 +827,15 @@ defmodule Ash.Query do
end
end
defp maybe_select(query, field) do
if query.select do
Ash.Query.select(query, List.wrap(field))
else
to_select =
query.resource
|> Ash.Resource.Info.attributes()
|> Enum.map(& &1.name)
@doc false
def validate_calculation_arguments(calculation, args) do
args =
if Keyword.keyword?(args) do
Map.new(args)
else
args
end
Ash.Query.select(query, to_select)
end
end
defp validate_arguments(calculation, args) do
Enum.reduce_while(calculation.arguments, {:ok, %{}}, fn argument, {:ok, arg_values} ->
value = default(Map.get(args, argument.name), argument.default)
@ -768,13 +846,17 @@ defmodule Ash.Query do
{:halt, {:error, "Argument #{argument.name} is required"}}
end
else
with {:ok, casted} <- Ash.Type.cast_input(argument.type, value, argument.constraints),
{:ok, casted} <-
Ash.Type.apply_constraints(argument.type, casted, argument.constraints) do
{:cont, {:ok, Map.put(arg_values, argument.name, casted)}}
if !Map.get(args, argument.name) && value do
{:cont, {:ok, Map.put(arg_values, argument.name, value)}}
else
{:error, error} ->
{:halt, {:error, error}}
with {:ok, casted} <- Ash.Type.cast_input(argument.type, value, argument.constraints),
{:ok, casted} <-
Ash.Type.apply_constraints(argument.type, casted, argument.constraints) do
{:cont, {:ok, Map.put(arg_values, argument.name, casted)}}
else
{:error, error} ->
{:halt, {:error, error}}
end
end
end
end)
@ -1054,11 +1136,11 @@ defmodule Ash.Query do
{:aggregate, {name, type, relationship, agg_query}}, query ->
aggregate(query, name, type, relationship, agg_query)
{:calculate, {name, module_and_opts}}, query ->
calculate(query, name, module_and_opts)
{:calculate, {name, module_and_opts, type}}, query ->
calculate(query, name, module_and_opts, type)
{:calculate, {name, module_and_opts, context}}, query ->
calculate(query, name, module_and_opts, context)
{:calculate, {name, module_and_opts, type, context}}, query ->
calculate(query, name, module_and_opts, type, context)
{:context, context}, query ->
set_context(query, context)
@ -1130,7 +1212,7 @@ defmodule Ash.Query do
More features for calculations, like passing anonymous functions, will be supported in the future.
"""
def calculate(query, name, module_and_opts, context \\ %{}) do
def calculate(query, name, module_and_opts, type, context \\ %{}) do
query = to_query(query)
{module, opts} =
@ -1139,8 +1221,19 @@ defmodule Ash.Query do
module -> {module, []}
end
case Calculation.new(name, module, opts, context) do
case Calculation.new(name, module, opts, type, Map.put(context, :context, query.context)) do
{:ok, calculation} ->
fields_to_select = module.select(query, opts, calculation.context) || []
query =
query
|> module.load(
opts,
calculation.context
|> Map.put(:context, query.context)
)
calculation = %{calculation | select: fields_to_select}
%{query | calculations: Map.put(query.calculations, name, calculation)}
{:error, error} ->
@ -1306,7 +1399,13 @@ defmodule Ash.Query do
{:ok, filter}
existing_filter ->
Ash.Filter.add_to_filter(existing_filter, filter, :and, query.aggregates)
Ash.Filter.add_to_filter(
existing_filter,
filter,
:and,
query.aggregates,
query.calculations
)
end
case new_filter do
@ -1333,13 +1432,22 @@ defmodule Ash.Query do
|> Ash.Resource.Info.aggregates()
|> Enum.map(& &1.name)
temp_query = Ash.Query.load(query, agg_names)
filter =
if query.filter do
Ash.Filter.add_to_filter(query.filter, statement, :and, query.aggregates)
Ash.Filter.add_to_filter(
query.filter,
statement,
:and,
query.aggregates,
query.calculations
)
else
Ash.Filter.parse(query.resource, statement, temp_query.aggregates)
Ash.Filter.parse(
query.resource,
statement,
query.aggregates,
query.calculations
)
end
case filter do
@ -1351,8 +1459,38 @@ defmodule Ash.Query do
|> Enum.filter(&(&1 in agg_names))
|> Enum.reject(&Map.has_key?(query.aggregates, &1))
aggs_to_load_for_calculations =
filter
|> Ash.Filter.used_calculations(
query.resource,
[],
query.calculations,
query.aggregates
)
|> Enum.flat_map(fn calculation ->
expression = calculation.module.expression(calculation.opts, calculation.context)
case Ash.Filter.hydrate_refs(expression, %{
resource: query.resource,
aggregates: query.aggregates,
calculations: query.calculations,
public?: false
}) do
{:ok, expression} ->
expression
|> Ash.Filter.used_aggregates([])
|> Enum.map(& &1.name)
_ ->
[]
end
end)
|> Enum.filter(&(&1 in agg_names))
|> Enum.reject(&Map.has_key?(query.aggregates, &1))
|> Enum.reject(&(&1 in aggregates_to_load))
query
|> Ash.Query.load(aggregates_to_load)
|> Ash.Query.load(aggregates_to_load ++ aggs_to_load_for_calculations)
|> Map.put(:filter, filter)
{:error, error} ->

View file

@ -8,7 +8,8 @@ defmodule Ash.Resource.Calculation do
:description,
:private?,
:allow_nil?,
:select
:select,
:load
]
@schema [
@ -41,6 +42,11 @@ defmodule Ash.Resource.Calculation do
default: [],
doc: "A list of fields to ensure selected in the case that the calculation is run."
],
load: [
type: :any,
default: [],
doc: "A load statement to be applied if the calculation is used."
],
allow_nil?: [
type: :boolean,
default: true,
@ -101,6 +107,6 @@ defmodule Ash.Resource.Calculation do
def calculation(module) when is_atom(module), do: {:ok, {module, []}}
def calculation(other) do
{:error, "Expected a module or {module, opts}, got: #{inspect(other)}"}
{:ok, {Ash.Resource.Calculation.Expression, expr: other}}
end
end

View file

@ -1,6 +1,7 @@
defmodule Ash.Resource.Calculation.Concat do
@moduledoc false
use Ash.Calculation, type: :string
use Ash.Calculation
require Ash.Query
def init(opts) do
if opts[:keys] && is_list(opts[:keys]) && Enum.all?(opts[:keys], &is_atom/1) do
@ -14,6 +15,20 @@ defmodule Ash.Resource.Calculation.Concat do
opts[:keys]
end
def expression(opts, _) do
Enum.reduce(opts[:keys], nil, fn key, expr ->
if expr do
if opts[:separator] do
Ash.Query.expr(expr <> ^opts[:separator] <> ref(^key))
else
Ash.Query.expr(expr <> ref(^key))
end
else
Ash.Query.expr(ref(^key))
end
end)
end
def calculate(records, opts, _) do
Enum.map(records, fn record ->
Enum.map_join(opts[:keys], opts[:separator] || "", fn key ->

View file

@ -0,0 +1,90 @@
defmodule Ash.Resource.Calculation.Expression do
@moduledoc false
use Ash.Calculation, type: :string
def expression(opts, context) do
expr =
Ash.Filter.build_filter_from_template(opts[:expr], nil, context, context[:context] || %{})
Ash.Filter.build_filter_from_template(expr, nil, context, context[:context] || %{})
end
def load(query, opts, context) do
expr =
Ash.Filter.build_filter_from_template(opts[:expr], nil, context, context[:context] || %{})
case Ash.Filter.hydrate_refs(expr, %{
resource: query.resource,
calculations: query.calculations,
aggregates: query.aggregates,
public?: false
}) do
{:ok, expression} ->
further_calculations =
expression
|> Ash.Filter.used_calculations(
query.resource,
query.calculations,
query.aggregates
)
aggs_from_this_calc =
expression
|> Ash.Filter.used_aggregates()
|> Enum.map(& &1.name)
aggs_from_calcs =
further_calculations
|> Enum.flat_map(fn calculation ->
calculation_context =
calculation.context
|> Map.put(:context, query.context)
case Ash.Filter.hydrate_refs(
calculation.module.expression(calculation.opts, calculation_context),
%{
resource: query.resource,
calculations: query.calculations,
aggregates: query.aggregates,
public?: false
}
) do
{:ok, expression} ->
expression
|> Ash.Filter.used_aggregates()
|> Enum.map(& &1.name)
_ ->
[]
end
end)
|> Enum.map(& &1.name)
names = Enum.uniq(aggs_from_calcs ++ aggs_from_this_calc)
Ash.Query.load(query, names)
{:error, _} ->
query
end
end
def select(query, opts, context) do
expr =
Ash.Filter.build_filter_from_template(opts[:expr], nil, context, context[:context] || %{})
case Ash.Filter.hydrate_refs(expr, %{
resource: query.resource,
calculations: query.calculations,
aggregates: query.aggregates,
public?: false
}) do
{:ok, expression} ->
expression
|> Ash.Filter.list_refs()
|> Enum.filter(&(&1.relationship_path != []))
|> Enum.filter(&match?(%{attribute: %Ash.Resource.Attribute{}}, &1))
|> Enum.map(& &1.attribute.name)
end
end
end

View file

@ -638,13 +638,17 @@ defmodule Ash.Resource.Dsl do
All functions will have an optional last argument that accepts options. Those options are:
#{Ash.OptionsHelpers.docs(Ash.Resource.Interface.interface_options())}
#{Ash.OptionsHelpers.docs(Ash.Resource.Interface.interface_options(nil))}
For reads:
* `:query` - a query to start the action with, can be used to filter/sort the results of the action.
They will also have an optional third argument that is a freeform map to provide action input. It *must be a map*.
For creates:
* `:changeset` - a changeset to start the action with
They will also have an optional second to last argument that is a freeform map to provide action input. It *must be a map*.
If it is a keyword list, it will be assumed that it is actually `options` (for convenience).
This allows for the following behaviour:
@ -885,7 +889,8 @@ defmodule Ash.Resource.Dsl do
"""
],
imports: [
Module.concat(["Ash", Resource, Calculation, Builtins])
Module.concat(["Ash", Resource, Calculation, Builtins]),
Module.concat(["Ash", Filter.TemplateHelpers])
],
entities: [
@calculation

View file

@ -6,7 +6,7 @@ defmodule Ash.Resource.Interface do
@type t :: %__MODULE__{}
def interface_options do
def interface_options(action_type) do
[
tenant: [
type: :any,
@ -31,9 +31,29 @@ defmodule Ash.Resource.Interface do
type: :boolean,
doc: "a flag to toggle verbose output from the internal Ash engine (for debugging)"
]
] ++ action_type_opts(action_type)
end
defp action_type_opts(:create) do
[
changeset: [
type: :any,
doc: "A changeset to seed the action with."
]
]
end
defp action_type_opts(:read) do
[
query: [
type: :any,
doc: "A query to seed the action with."
]
]
end
defp action_type_opts(_), do: []
@schema [
name: [
type: :atom,

View file

@ -64,7 +64,7 @@ defmodule Ash.Resource.Relationships.ManyToMany do
type: :atom,
required: true,
doc:
"The field on the join table that should line up with `destination_field` on the related resource. Default: [relationshihp_name]_id"
"The field on the join table that should line up with `destination_field` on the related resource."
],
through: [
type: :atom,

View file

@ -50,7 +50,7 @@ defmodule Ash.Schema do
for calculation <- Ash.Resource.Info.calculations(__MODULE__) do
{mod, _} = calculation.calculation
field(calculation.name, Ash.Type.ecto_type(mod.type()), virtual: true)
field(calculation.name, Ash.Type.ecto_type(calculation.type), virtual: true)
struct_fields = Keyword.delete(@struct_fields, calculation.name)
Module.delete_attribute(__MODULE__, :struct_fields)
@ -105,7 +105,7 @@ defmodule Ash.Schema do
for calculation <- Ash.Resource.Info.calculations(__MODULE__) do
{mod, _} = calculation.calculation
field(calculation.name, Ash.Type.ecto_type(mod.type()), virtual: true)
field(calculation.name, Ash.Type.ecto_type(calculation.type), virtual: true)
struct_fields = Keyword.delete(@struct_fields, calculation.name)
Module.delete_attribute(__MODULE__, :struct_fields)

View file

@ -94,14 +94,12 @@ defmodule Ash.Resource.Transformers.SetTypes do
case new_arguments do
{:ok, new_args} ->
type = Ash.Type.get_type(calculation.type)
{:cont,
{:ok,
Transformer.replace_entity(
dsl_state,
[:calculations],
%{calculation | arguments: Enum.reverse(new_args), type: type},
%{calculation | arguments: Enum.reverse(new_args)},
fn replacing ->
replacing.name == calculation.name
end

View file

@ -240,20 +240,20 @@ defmodule Ash.SatSolver do
end)
end
def synonymous_relationship_paths?(_, [], []), do: true
# def synonymous_relationship_paths?(_, [], []), do: true
def synonymous_relationship_paths?(_resource, candidate_path, path)
when length(candidate_path) != length(path),
do: false
# def synonymous_relationship_paths?(_resource, candidate_path, path)
# when length(candidate_path) != length(path),
# do: false
def synonymous_relationship_paths?(resource, [candidate_first | candidate_rest], [first | rest])
when first == candidate_first do
synonymous_relationship_paths?(
Ash.Resource.Info.relationship(resource, candidate_first).destination,
candidate_rest,
rest
)
end
# def synonymous_relationship_paths?(resource, [candidate_first | candidate_rest], [first | rest])
# when first == candidate_first do
# synonymous_relationship_paths?(
# Ash.Resource.Info.relationship(resource, candidate_first).destination,
# candidate_rest,
# rest
# )
# end
def synonymous_relationship_paths?(
left_resource,
@ -268,8 +268,8 @@ defmodule Ash.SatSolver do
def synonymous_relationship_paths?(
left_resource,
[candidate_first | candidate_rest] = candidate,
[first | rest] = search,
[candidate_first | candidate_rest],
[first | rest],
right_resource
) do
right_resource = right_resource || left_resource
@ -281,20 +281,26 @@ defmodule Ash.SatSolver do
false
relationship.type == :many_to_many && candidate_relationship.type == :has_many ->
synonymous_relationship_paths?(
left_resource,
[relationship.join_relationship | candidate],
search,
right_resource
)
synonymous_relationship_paths?(left_resource, [relationship.join_relationship], [
candidate_first
]) &&
synonymous_relationship_paths?(
left_resource,
candidate_rest,
rest,
right_resource
)
relationship.type == :has_many && candidate_relationship.type == :many_to_many ->
synonymous_relationship_paths?(
left_resource,
candidate,
[candidate_relationship.join_relationship | search],
right_resource
)
synonymous_relationship_paths?(left_resource, [relationship.name], [
candidate_relationship.join_relationship
]) &&
synonymous_relationship_paths?(
left_resource,
candidate_rest,
rest,
right_resource
)
true ->
comparison_keys = [

View file

@ -416,7 +416,7 @@ defmodule Ash.Test.Actions.UpdateTest do
on_match: :update,
on_lookup: :relate
)
|> Api.update!(stacktraces?: true)
|> Api.update!()
|> Api.load!(:related_posts_join_assoc)
types = Enum.sort(Enum.map(new_post.related_posts_join_assoc, &Map.get(&1, :type)))

View file

@ -4,7 +4,7 @@ defmodule Ash.Test.CalculationTest do
defmodule Concat do
# An example concatenation calculation, that accepts the delimeter as an argument
use Ash.Calculation, type: :string
use Ash.Calculation
def init(opts) do
if opts[:keys] && is_list(opts[:keys]) && Enum.all?(opts[:keys], &is_atom/1) do
@ -52,6 +52,12 @@ defmodule Ash.Test.CalculationTest do
default: " ",
constraints: [allow_empty?: true, trim?: false]
end
calculate :expr_full_name, :string, expr(first_name <> " " <> last_name)
calculate :conditional_full_name,
:string,
expr(if(first_name and last_name, first_name <> " " <> last_name, "(none)"))
end
end
@ -113,7 +119,7 @@ defmodule Ash.Test.CalculationTest do
test "custom calculations can be added to a query" do
full_names =
User
|> Ash.Query.calculate(:full_name, {Concat, keys: [:first_name, :last_name]}, %{
|> Ash.Query.calculate(:full_name, {Concat, keys: [:first_name, :last_name]}, :string, %{
separator: " \o.o/ "
})
|> Api.read!()
@ -122,4 +128,30 @@ defmodule Ash.Test.CalculationTest do
assert full_names == ["brian \o.o/ cranston", "zach \o.o/ daniel"]
end
test "expression based calculations are resolved via evaluating the expression" do
full_names =
User
|> Ash.Query.load(:expr_full_name)
|> Api.read!()
|> Enum.map(& &1.expr_full_name)
|> Enum.sort()
assert full_names == ["brian cranston", "zach daniel"]
end
test "the `if` calculation resolves the first expr when true, and the second when false" do
User
|> Ash.Changeset.new(%{first_name: "bob"})
|> Api.create!()
full_names =
User
|> Ash.Query.load(:conditional_full_name)
|> Api.read!()
|> Enum.map(& &1.conditional_full_name)
|> Enum.sort()
assert full_names == ["(none)", "brian cranston", "zach daniel"]
end
end

View file

@ -450,7 +450,7 @@ defmodule Ash.Test.Changeset.ChangesetTest do
Author
|> Changeset.new()
|> Changeset.manage_relationship(:posts, [post1, post2], on_no_match: :error)
|> Api.create!(stacktraces?: true)
|> Api.create!()
end
end
@ -469,7 +469,7 @@ defmodule Ash.Test.Changeset.ChangesetTest do
author
|> Changeset.new()
|> Changeset.manage_relationship(:posts, [], on_missing: :destroy)
|> Api.update!(stacktraces?: true)
|> Api.update!()
assert [] = Api.read!(Post)
end
@ -491,7 +491,7 @@ defmodule Ash.Test.Changeset.ChangesetTest do
author
|> Changeset.new()
|> Changeset.manage_relationship(:unique_posts, [%{title: "title"}], on_missing: :unrelate)
|> Api.update!(stacktraces?: true)
|> Api.update!()
assert [%{title: "title"}, %{title: "title1"}] =
Enum.sort_by(Api.read!(UniqueNamePerAuthor), & &1.title)
@ -515,7 +515,7 @@ defmodule Ash.Test.Changeset.ChangesetTest do
author
|> Changeset.new()
|> Changeset.manage_relationship(:posts, [], on_missing: :unrelate)
|> Api.update!(stacktraces?: true)
|> Api.update!()
assert [%{title: "title"}, %{title: "title"}] = Api.read!(Post)
@ -635,7 +635,7 @@ defmodule Ash.Test.Changeset.ChangesetTest do
CompositeKeyPost
|> Ash.Query.load(author: :composite_key_posts)
|> Ash.Query.filter(id == ^post1.id and serial == ^post1.serial)
|> Api.read!(stacktraces?: true)
|> Api.read!()
assert Api.reload!(author) == Api.reload!(fetched_post.author)
end

View file

@ -162,7 +162,7 @@ defmodule Ash.Test.Changeset.EmbeddedResourceTest do
]
}
)
|> Api.create!(stacktraces?: true)
|> Api.create!()
end
test "embedded resources support calculations" do