mirror of
https://github.com/ash-project/ash.git
synced 2024-09-19 21:13:10 +12:00
feat: expression calculations for sorting/filtering
improvement: small improvements/fixes across the board
This commit is contained in:
parent
c0cd039ae2
commit
231eeafd30
44 changed files with 1753 additions and 491 deletions
|
@ -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, []},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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} ->
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}}
|
||||
|
||||
|
|
|
@ -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])
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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})
|
||||
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
20
lib/ash/error/query/calculations_not_supported copy.ex
Normal file
20
lib/ash/error/query/calculations_not_supported copy.ex
Normal 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
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
}}
|
||||
|
|
|
@ -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: ", ")
|
||||
])
|
||||
|
|
16
lib/ash/query/function/if.ex
Normal file
16
lib/ash/query/function/if.ex
Normal 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
|
|
@ -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,
|
||||
|
|
|
@ -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} ->
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ->
|
||||
|
|
90
lib/ash/resource/calculation/expression.ex
Normal file
90
lib/ash/resource/calculation/expression.ex
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -162,7 +162,7 @@ defmodule Ash.Test.Changeset.EmbeddedResourceTest do
|
|||
]
|
||||
}
|
||||
)
|
||||
|> Api.create!(stacktraces?: true)
|
||||
|> Api.create!()
|
||||
end
|
||||
|
||||
test "embedded resources support calculations" do
|
||||
|
|
Loading…
Reference in a new issue