mirror of
https://github.com/ash-project/ash.git
synced 2024-09-20 05:23:03 +12:00
feat: add named aggregates
This commit is contained in:
parent
094b39d27f
commit
be74d1e7eb
29 changed files with 526 additions and 241 deletions
|
@ -7,6 +7,8 @@ locals_without_parens = [
|
||||||
belongs_to: 2,
|
belongs_to: 2,
|
||||||
belongs_to: 3,
|
belongs_to: 3,
|
||||||
constraints: 1,
|
constraints: 1,
|
||||||
|
count: 2,
|
||||||
|
count: 3,
|
||||||
create: 1,
|
create: 1,
|
||||||
create: 2,
|
create: 2,
|
||||||
create_timestamp: 1,
|
create_timestamp: 1,
|
||||||
|
@ -20,6 +22,7 @@ locals_without_parens = [
|
||||||
destroy: 2,
|
destroy: 2,
|
||||||
expensive?: 1,
|
expensive?: 1,
|
||||||
field_type: 1,
|
field_type: 1,
|
||||||
|
filter: 1,
|
||||||
generated?: 1,
|
generated?: 1,
|
||||||
has_many: 2,
|
has_many: 2,
|
||||||
has_many: 3,
|
has_many: 3,
|
||||||
|
@ -27,6 +30,7 @@ locals_without_parens = [
|
||||||
has_one: 3,
|
has_one: 3,
|
||||||
join_attributes: 1,
|
join_attributes: 1,
|
||||||
join_relationship: 1,
|
join_relationship: 1,
|
||||||
|
kind: 1,
|
||||||
many_to_many: 2,
|
many_to_many: 2,
|
||||||
many_to_many: 3,
|
many_to_many: 3,
|
||||||
on: 1,
|
on: 1,
|
||||||
|
|
|
@ -21,25 +21,6 @@ defmodule MyApp.Api do
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, add `MyApp.Api` to your `application.ex`'s start function, which should look something like this:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
def start(_type, _args) do
|
|
||||||
children = [
|
|
||||||
# Start the Ecto repository
|
|
||||||
MyApp.Repo,
|
|
||||||
# Start the Telemetry supervisor
|
|
||||||
MyApp.Telemetry,
|
|
||||||
# Start the PubSub system
|
|
||||||
{Phoenix.PubSub, name: MyApp.PubSub},
|
|
||||||
# Start the Endpoint (http/https)
|
|
||||||
MyApp.Endpoint,
|
|
||||||
MyApp.Api # <- Add your API here
|
|
||||||
]
|
|
||||||
...
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
## Create a resource
|
## Create a resource
|
||||||
|
|
||||||
A resource is the primary entity in Ash. Your Api module ties your resources together and gives them an interface, but the vast majority if your configuration will live in a resource. In your typical setup, you might have a resource per database table. For those already familiar with ecto, a resource and an ecto schema are very similar. In fact, all resources define an ecto schema under the hood. This can be leveraged when you need to do things that are not yet implemented or fall outside of the scope of Ash. The current reccomendation for where to put your resources is in `lib/resources/<resource_name>.ex`. Here are a few examples:
|
A resource is the primary entity in Ash. Your Api module ties your resources together and gives them an interface, but the vast majority if your configuration will live in a resource. In your typical setup, you might have a resource per database table. For those already familiar with ecto, a resource and an ecto schema are very similar. In fact, all resources define an ecto schema under the hood. This can be leveraged when you need to do things that are not yet implemented or fall outside of the scope of Ash. The current reccomendation for where to put your resources is in `lib/resources/<resource_name>.ex`. Here are a few examples:
|
||||||
|
@ -146,5 +127,5 @@ end
|
||||||
|
|
||||||
- `Ash.Api` for what you can do with your resources.
|
- `Ash.Api` for what you can do with your resources.
|
||||||
- `Ash.Query` for the kinds of queries you can make.
|
- `Ash.Query` for the kinds of queries you can make.
|
||||||
- `Ash.Dsl` for the resource DSL documentation.
|
- `Ash.Resource.Dsl` for the resource DSL documentation.
|
||||||
- `Ash.Api.Dsl` for the API DSL documentation.
|
- `Ash.Api.Dsl` for the API DSL documentation.
|
||||||
|
|
|
@ -10,7 +10,7 @@ defmodule Ash do
|
||||||
|
|
||||||
## Quick Links
|
## Quick Links
|
||||||
|
|
||||||
- [Resource DSL Documentation](Ash.Dsl.html)
|
- [Resource DSL Documentation](Ash.Resource.Dsl.html)
|
||||||
- [Api DSL Documentation](Ash.Api.Dsl.html)
|
- [Api DSL Documentation](Ash.Api.Dsl.html)
|
||||||
- [Api interface documentation](Ash.Api.html)
|
- [Api interface documentation](Ash.Api.html)
|
||||||
- [Query Documentation](Ash.Query.html)
|
- [Query Documentation](Ash.Query.html)
|
||||||
|
@ -68,7 +68,7 @@ defmodule Ash do
|
||||||
@type action_type :: :read | :create | :update | :destroy
|
@type action_type :: :read | :create | :update | :destroy
|
||||||
@type actor :: Ash.record()
|
@type actor :: Ash.record()
|
||||||
@type aggregate :: Ash.Query.Aggregate.t()
|
@type aggregate :: Ash.Query.Aggregate.t()
|
||||||
@type aggregate_type :: Ash.Query.Aggregate.type()
|
@type aggregate_kind :: Ash.Query.Aggregate.kind()
|
||||||
@type api :: module
|
@type api :: module
|
||||||
@type attribute :: Ash.Resource.Attribute.t()
|
@type attribute :: Ash.Resource.Attribute.t()
|
||||||
@type cardinality_many_relationship() :: HasMany.t() | ManyToMany.t()
|
@type cardinality_many_relationship() :: HasMany.t() | ManyToMany.t()
|
||||||
|
@ -77,7 +77,7 @@ defmodule Ash do
|
||||||
@type data_layer :: module
|
@type data_layer :: module
|
||||||
@type data_layer_query :: struct
|
@type data_layer_query :: struct
|
||||||
@type error :: struct
|
@type error :: struct
|
||||||
@type filter :: map()
|
@type filter :: Ash.Filter.t()
|
||||||
@type params :: Keyword.t()
|
@type params :: Keyword.t()
|
||||||
@type primary_key :: record() | map | term
|
@type primary_key :: record() | map | term
|
||||||
@type query :: Ash.Query.t()
|
@type query :: Ash.Query.t()
|
||||||
|
@ -85,7 +85,7 @@ defmodule Ash do
|
||||||
@type relationship :: cardinality_one_relationship() | cardinality_many_relationship()
|
@type relationship :: cardinality_one_relationship() | cardinality_many_relationship()
|
||||||
@type relationship_cardinality :: :many | :one
|
@type relationship_cardinality :: :many | :one
|
||||||
@type resource :: module
|
@type resource :: module
|
||||||
@type side_loads :: Keyword.t()
|
@type side_loads :: [{atom, [atom | query()]} | atom] | atom
|
||||||
@type sort :: Keyword.t()
|
@type sort :: Keyword.t()
|
||||||
@type validation :: Ash.Resource.Validation.t()
|
@type validation :: Ash.Resource.Validation.t()
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
defmodule Ash.Actions.Create do
|
defmodule Ash.Actions.Create do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
alias Ash.Actions.Relationships
|
||||||
alias Ash.Engine
|
alias Ash.Engine
|
||||||
alias Ash.Engine.Request
|
alias Ash.Engine.Request
|
||||||
alias Ash.Actions.{Relationships, SideLoad}
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
@spec run(Ash.api(), Ash.changeset(), Ash.action(), Keyword.t()) ::
|
||||||
|
{:ok, Ash.record()} | {:error, Ash.error()}
|
||||||
def run(api, changeset, action, opts) do
|
def run(api, changeset, action, opts) do
|
||||||
side_load = opts[:side_load] || []
|
|
||||||
upsert? = opts[:upsert?] || false
|
upsert? = opts[:upsert?] || false
|
||||||
resource = changeset.resource
|
resource = changeset.resource
|
||||||
|
|
||||||
|
@ -17,12 +18,8 @@ defmodule Ash.Actions.Create do
|
||||||
|
|
||||||
with %{valid?: true} = changeset <- changeset(changeset, api),
|
with %{valid?: true} = changeset <- changeset(changeset, api),
|
||||||
:ok <- check_upsert_support(changeset.resource, upsert?),
|
:ok <- check_upsert_support(changeset.resource, upsert?),
|
||||||
{:ok, side_load_query} <-
|
|
||||||
side_loads_as_query(changeset.api, changeset.resource, side_load),
|
|
||||||
side_load_requests <-
|
|
||||||
SideLoad.requests(side_load_query),
|
|
||||||
%{
|
%{
|
||||||
data: %{commit: %^resource{} = created} = state,
|
data: %{commit: %^resource{} = created},
|
||||||
errors: []
|
errors: []
|
||||||
} <-
|
} <-
|
||||||
do_run_requests(
|
do_run_requests(
|
||||||
|
@ -31,10 +28,9 @@ defmodule Ash.Actions.Create do
|
||||||
engine_opts,
|
engine_opts,
|
||||||
action,
|
action,
|
||||||
resource,
|
resource,
|
||||||
api,
|
api
|
||||||
side_load_requests
|
|
||||||
) do
|
) do
|
||||||
{:ok, SideLoad.attach_side_loads(created, state)}
|
{:ok, created}
|
||||||
else
|
else
|
||||||
%Ash.Changeset{errors: errors} ->
|
%Ash.Changeset{errors: errors} ->
|
||||||
{:error, Ash.Error.to_ash_error(errors)}
|
{:error, Ash.Error.to_ash_error(errors)}
|
||||||
|
@ -92,8 +88,7 @@ defmodule Ash.Actions.Create do
|
||||||
engine_opts,
|
engine_opts,
|
||||||
action,
|
action,
|
||||||
resource,
|
resource,
|
||||||
api,
|
api
|
||||||
side_load_requests
|
|
||||||
) do
|
) do
|
||||||
authorization_request =
|
authorization_request =
|
||||||
Request.new(
|
Request.new(
|
||||||
|
@ -135,8 +130,7 @@ defmodule Ash.Actions.Create do
|
||||||
)
|
)
|
||||||
|
|
||||||
Engine.run(
|
Engine.run(
|
||||||
[authorization_request | [commit_request | relationship_read_requests]] ++
|
[authorization_request | [commit_request | relationship_read_requests]],
|
||||||
side_load_requests,
|
|
||||||
api,
|
api,
|
||||||
engine_opts
|
engine_opts
|
||||||
)
|
)
|
||||||
|
@ -152,20 +146,6 @@ defmodule Ash.Actions.Create do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp side_loads_as_query(_api, _resource, nil), do: {:ok, nil}
|
|
||||||
defp side_loads_as_query(_api, _resource, %Ash.Query{errors: []} = query), do: {:ok, query}
|
|
||||||
defp side_loads_as_query(_api, _resource, %Ash.Query{errors: errors}), do: {:error, errors}
|
|
||||||
|
|
||||||
defp side_loads_as_query(api, resource, side_loads) when is_list(side_loads) do
|
|
||||||
resource
|
|
||||||
|> Ash.Query.new(api)
|
|
||||||
|> Ash.Query.side_load(side_loads)
|
|
||||||
|> case do
|
|
||||||
%{errors: []} = query -> {:ok, query}
|
|
||||||
%{errors: errors} -> {:error, errors}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp default({:constant, value}), do: value
|
defp default({:constant, value}), do: value
|
||||||
defp default({mod, func, args}), do: apply(mod, func, args)
|
defp default({mod, func, args}), do: apply(mod, func, args)
|
||||||
defp default(function) when is_function(function, 0), do: function.()
|
defp default(function) when is_function(function, 0), do: function.()
|
||||||
|
|
|
@ -10,6 +10,8 @@ defmodule Ash.Actions.Read do
|
||||||
def run(query, action, opts \\ []) do
|
def run(query, action, opts \\ []) do
|
||||||
engine_opts = Keyword.take(opts, [:verbose?, :actor, :authorize?])
|
engine_opts = Keyword.take(opts, [:verbose?, :actor, :authorize?])
|
||||||
|
|
||||||
|
query = query_with_initial_data(query, opts)
|
||||||
|
|
||||||
with %{errors: []} <- query,
|
with %{errors: []} <- query,
|
||||||
{:ok, requests} <- requests(query, action, opts),
|
{:ok, requests} <- requests(query, action, opts),
|
||||||
side_load_requests <- SideLoad.requests(query),
|
side_load_requests <- SideLoad.requests(query),
|
||||||
|
@ -19,6 +21,7 @@ defmodule Ash.Actions.Read do
|
||||||
data_with_aggregates <-
|
data_with_aggregates <-
|
||||||
add_aggregate_values(
|
add_aggregate_values(
|
||||||
data_with_side_loads,
|
data_with_side_loads,
|
||||||
|
query.aggregates,
|
||||||
query.resource,
|
query.resource,
|
||||||
Map.get(all_data, :aggregate_values, %{})
|
Map.get(all_data, :aggregate_values, %{})
|
||||||
) do
|
) do
|
||||||
|
@ -32,18 +35,49 @@ defmodule Ash.Actions.Read do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp query_with_initial_data(query, opts) do
|
||||||
|
case Keyword.fetch(opts, :initial_data) do
|
||||||
|
:error ->
|
||||||
|
query
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
Ash.Query.filter(query, false)
|
||||||
|
|
||||||
|
{:ok, []} ->
|
||||||
|
Ash.Query.filter(query, false)
|
||||||
|
|
||||||
|
{:ok, [record]} ->
|
||||||
|
pkey_value = record |> Map.take(Ash.Resource.primary_key(query.resource)) |> Map.to_list()
|
||||||
|
|
||||||
|
Ash.Query.filter(query, pkey_value)
|
||||||
|
|
||||||
|
{:ok, %{} = record} ->
|
||||||
|
pkey_value = record |> Map.take(Ash.Resource.primary_key(query.resource)) |> Map.to_list()
|
||||||
|
|
||||||
|
Ash.Query.filter(query, pkey_value)
|
||||||
|
|
||||||
|
{:ok, records} when is_list(records) ->
|
||||||
|
pkey = Ash.Resource.primary_key(query.resource)
|
||||||
|
pkey_value = Enum.map(records, fn record -> record |> Map.take(pkey) |> Map.to_list() end)
|
||||||
|
|
||||||
|
Ash.Query.filter(query, or: pkey_value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp requests(query, action, opts) do
|
defp requests(query, action, opts) do
|
||||||
filter_requests =
|
filter_requests =
|
||||||
if Keyword.has_key?(opts, :actor) || opts[:authorize?] do
|
if not Keyword.has_key?(opts, :initial_data) &&
|
||||||
|
(Keyword.has_key?(opts, :actor) || opts[:authorize?]) do
|
||||||
Filter.read_requests(query.api, query.filter)
|
Filter.read_requests(query.api, query.filter)
|
||||||
else
|
else
|
||||||
{:ok, []}
|
{:ok, []}
|
||||||
end
|
end
|
||||||
|
|
||||||
authorizing? = Keyword.has_key?(opts, :actor) || opts[:authorize?]
|
authorizing? = Keyword.has_key?(opts, :actor) || opts[:authorize?]
|
||||||
|
can_be_in_query? = not Keyword.has_key?(opts, :initial_data)
|
||||||
|
|
||||||
{aggregate_auth_requests, aggregate_value_requests, aggregates_in_query} =
|
{aggregate_auth_requests, aggregate_value_requests, aggregates_in_query} =
|
||||||
Aggregate.requests(query, authorizing?)
|
Aggregate.requests(query, can_be_in_query?, authorizing?)
|
||||||
|
|
||||||
case filter_requests do
|
case filter_requests do
|
||||||
{:ok, filter_requests} ->
|
{:ok, filter_requests} ->
|
||||||
|
@ -53,6 +87,7 @@ defmodule Ash.Actions.Read do
|
||||||
api: query.api,
|
api: query.api,
|
||||||
query: query,
|
query: query,
|
||||||
action: action,
|
action: action,
|
||||||
|
authorize?: not Keyword.has_key?(opts, :initial_data),
|
||||||
data:
|
data:
|
||||||
data_field(
|
data_field(
|
||||||
opts,
|
opts,
|
||||||
|
@ -125,10 +160,11 @@ defmodule Ash.Actions.Read do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp add_aggregate_values(results, _resource, aggregate_values) when aggregate_values == %{},
|
defp add_aggregate_values(results, _aggregates, _resource, aggregate_values)
|
||||||
|
when aggregate_values == %{},
|
||||||
do: Enum.map(results, &Map.update!(&1, :aggregates, fn agg -> agg || %{} end))
|
do: Enum.map(results, &Map.update!(&1, :aggregates, fn agg -> agg || %{} end))
|
||||||
|
|
||||||
defp add_aggregate_values(results, resource, aggregate_values) do
|
defp add_aggregate_values(results, aggregates, resource, aggregate_values) do
|
||||||
keys_to_aggregates =
|
keys_to_aggregates =
|
||||||
Enum.reduce(aggregate_values, %{}, fn {_name, keys_to_values}, acc ->
|
Enum.reduce(aggregate_values, %{}, fn {_name, keys_to_values}, acc ->
|
||||||
Enum.reduce(keys_to_values, acc, fn {pkey, values}, acc ->
|
Enum.reduce(keys_to_values, acc, fn {pkey, values}, acc ->
|
||||||
|
@ -138,9 +174,17 @@ defmodule Ash.Actions.Read do
|
||||||
|
|
||||||
pkey = Ash.Resource.primary_key(resource)
|
pkey = Ash.Resource.primary_key(resource)
|
||||||
|
|
||||||
|
loaded =
|
||||||
|
aggregates
|
||||||
|
|> Enum.map(fn {_, aggregate} -> aggregate.load end)
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|
||||||
Enum.map(results, fn result ->
|
Enum.map(results, fn result ->
|
||||||
aggregate_values = Map.get(keys_to_aggregates, Map.take(result, pkey), %{})
|
aggregate_values = Map.get(keys_to_aggregates, Map.take(result, pkey), %{})
|
||||||
%{result | aggregates: Map.merge(result.aggregates || %{}, aggregate_values)}
|
|
||||||
|
{top_level, nested} = Map.split(aggregate_values || %{}, loaded)
|
||||||
|
|
||||||
|
Map.merge(%{result | aggregates: Map.merge(result.aggregates, nested)}, top_level)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -584,14 +584,14 @@ defmodule Ash.Actions.Relationships do
|
||||||
|
|
||||||
defp add_to_set_relationship(record, relationship_name, to_relate) do
|
defp add_to_set_relationship(record, relationship_name, to_relate) do
|
||||||
Map.update!(record, relationship_name, fn
|
Map.update!(record, relationship_name, fn
|
||||||
%Ecto.Association.NotLoaded{} -> [clear_relationships(to_relate)]
|
%Ash.NotLoaded{type: :relationship} -> [clear_relationships(to_relate)]
|
||||||
set_relationship -> [clear_relationships(to_relate) | set_relationship]
|
set_relationship -> [clear_relationships(to_relate) | set_relationship]
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp remove_from_set_relationship(record, relationship_name, to_remove, pkey) do
|
defp remove_from_set_relationship(record, relationship_name, to_remove, pkey) do
|
||||||
Map.update!(record, relationship_name, fn
|
Map.update!(record, relationship_name, fn
|
||||||
%Ecto.Association.NotLoaded{} ->
|
%Ash.NotLoaded{type: :relationship} ->
|
||||||
[]
|
[]
|
||||||
|
|
||||||
set_relationship ->
|
set_relationship ->
|
||||||
|
@ -777,10 +777,9 @@ defmodule Ash.Actions.Relationships do
|
||||||
resource
|
resource
|
||||||
|> Ash.Resource.relationships()
|
|> Ash.Resource.relationships()
|
||||||
|> Enum.reduce(record, fn relationship, record ->
|
|> Enum.reduce(record, fn relationship, record ->
|
||||||
not_loaded = %Ecto.Association.NotLoaded{
|
not_loaded = %Ash.NotLoaded{
|
||||||
__cardinality__: relationship.cardinality,
|
type: :relationship,
|
||||||
__field__: relationship.name,
|
field: relationship.name
|
||||||
__owner__: relationship.source
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Map.put(record, relationship.name, not_loaded)
|
Map.put(record, relationship.name, not_loaded)
|
||||||
|
|
|
@ -54,41 +54,6 @@ defmodule Ash.Actions.SideLoad do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
def side_load(data, query, opts \\ [])
|
|
||||||
def side_load([], _query, _opts), do: {:ok, []}
|
|
||||||
def side_load(nil, _query, _opts), do: {:ok, nil}
|
|
||||||
|
|
||||||
def side_load(data, %{side_load: []}, _opts), do: {:ok, data}
|
|
||||||
|
|
||||||
def side_load(data, query, opts) when not is_list(data) do
|
|
||||||
data
|
|
||||||
|> List.wrap()
|
|
||||||
|> side_load(query, opts)
|
|
||||||
|> case do
|
|
||||||
{:ok, [record]} -> {:ok, record}
|
|
||||||
{:error, error} -> {:error, error}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def side_load([%resource{} | _] = data, side_load_query, opts) do
|
|
||||||
api = side_load_query.api
|
|
||||||
pkey = Ash.Resource.primary_key(resource)
|
|
||||||
|
|
||||||
pkey_filters = Enum.map(data, &Map.take(&1, pkey))
|
|
||||||
|
|
||||||
new_query = Ash.Query.filter(side_load_query, or: pkey_filters)
|
|
||||||
|
|
||||||
requests = requests(new_query, false, data)
|
|
||||||
|
|
||||||
case Engine.run(requests, api, opts) do
|
|
||||||
%{data: %{side_load: _} = state, errors: errors} when errors == [] ->
|
|
||||||
{:ok, attach_side_loads(data, state)}
|
|
||||||
|
|
||||||
%{errors: errors} ->
|
|
||||||
{:error, errors}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def attach_side_loads([%resource{} | _] = data, %{side_load: side_loads})
|
def attach_side_loads([%resource{} | _] = data, %{side_load: side_loads})
|
||||||
when is_list(data) do
|
when is_list(data) do
|
||||||
side_loads
|
side_loads
|
||||||
|
|
|
@ -1,15 +1,13 @@
|
||||||
defmodule Ash.Actions.Update do
|
defmodule Ash.Actions.Update do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
alias Ash.Actions.Relationships
|
||||||
alias Ash.Engine
|
alias Ash.Engine
|
||||||
alias Ash.Engine.Request
|
alias Ash.Engine.Request
|
||||||
alias Ash.Actions.{Relationships, SideLoad}
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@spec run(Ash.api(), Ash.record(), Ash.action(), Keyword.t()) ::
|
@spec run(Ash.api(), Ash.record(), Ash.action(), Keyword.t()) ::
|
||||||
{:ok, Ash.record()} | {:error, Ecto.Changeset.t()} | {:error, Ash.error()}
|
{:ok, Ash.record()} | {:error, Ecto.Changeset.t()} | {:error, Ash.error()}
|
||||||
def run(api, changeset, action, opts) do
|
def run(api, changeset, action, opts) do
|
||||||
side_load = opts[:side_load] || []
|
|
||||||
|
|
||||||
engine_opts =
|
engine_opts =
|
||||||
opts
|
opts
|
||||||
|> Keyword.take([:verbose?, :actor, :authorize?])
|
|> Keyword.take([:verbose?, :actor, :authorize?])
|
||||||
|
@ -18,20 +16,15 @@ defmodule Ash.Actions.Update do
|
||||||
resource = changeset.resource
|
resource = changeset.resource
|
||||||
|
|
||||||
with %{valid?: true} = changeset <- changeset(changeset, api),
|
with %{valid?: true} = changeset <- changeset(changeset, api),
|
||||||
{:ok, side_load_query} <-
|
%{data: %{commit: %^resource{} = updated}, errors: []} <-
|
||||||
side_loads_as_query(changeset.api, changeset.resource, side_load),
|
|
||||||
side_load_requests <-
|
|
||||||
SideLoad.requests(side_load_query),
|
|
||||||
%{data: %{commit: %^resource{} = updated}, errors: []} = state <-
|
|
||||||
do_run_requests(
|
do_run_requests(
|
||||||
changeset,
|
changeset,
|
||||||
engine_opts,
|
engine_opts,
|
||||||
action,
|
action,
|
||||||
resource,
|
resource,
|
||||||
api,
|
api
|
||||||
side_load_requests
|
|
||||||
) do
|
) do
|
||||||
{:ok, SideLoad.attach_side_loads(updated, state)}
|
{:ok, updated}
|
||||||
else
|
else
|
||||||
%Ash.Changeset{errors: errors} ->
|
%Ash.Changeset{errors: errors} ->
|
||||||
{:error, Ash.Error.to_ash_error(errors)}
|
{:error, Ash.Error.to_ash_error(errors)}
|
||||||
|
@ -82,8 +75,7 @@ defmodule Ash.Actions.Update do
|
||||||
engine_opts,
|
engine_opts,
|
||||||
action,
|
action,
|
||||||
resource,
|
resource,
|
||||||
api,
|
api
|
||||||
side_load_requests
|
|
||||||
) do
|
) do
|
||||||
authorization_request =
|
authorization_request =
|
||||||
Request.new(
|
Request.new(
|
||||||
|
@ -121,26 +113,12 @@ defmodule Ash.Actions.Update do
|
||||||
relationship_requests = changeset.requests
|
relationship_requests = changeset.requests
|
||||||
|
|
||||||
Engine.run(
|
Engine.run(
|
||||||
[authorization_request | [commit_request | relationship_requests]] ++ side_load_requests,
|
[authorization_request | [commit_request | relationship_requests]],
|
||||||
api,
|
api,
|
||||||
engine_opts
|
engine_opts
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp side_loads_as_query(_api, _resource, nil), do: {:ok, nil}
|
|
||||||
defp side_loads_as_query(_api, _resource, %Ash.Query{errors: []} = query), do: {:ok, query}
|
|
||||||
defp side_loads_as_query(_api, _resource, %Ash.Query{errors: errors}), do: {:error, errors}
|
|
||||||
|
|
||||||
defp side_loads_as_query(api, resource, side_loads) when is_list(side_loads) do
|
|
||||||
resource
|
|
||||||
|> Ash.Query.new(api)
|
|
||||||
|> Ash.Query.side_load(side_loads)
|
|
||||||
|> case do
|
|
||||||
%{errors: []} = query -> {:ok, query}
|
|
||||||
%{errors: errors} -> {:error, errors}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp default({:constant, value}), do: value
|
defp default({:constant, value}), do: value
|
||||||
defp default({mod, func, args}), do: apply(mod, func, args)
|
defp default({mod, func, args}), do: apply(mod, func, args)
|
||||||
defp default(function) when is_function(function, 0), do: function.()
|
defp default(function) when is_function(function, 0), do: function.()
|
||||||
|
|
|
@ -23,7 +23,7 @@ defmodule Ash.Api do
|
||||||
|
|
||||||
import Ash.OptionsHelpers, only: [merge_schemas: 3]
|
import Ash.OptionsHelpers, only: [merge_schemas: 3]
|
||||||
|
|
||||||
alias Ash.Actions.{Create, Destroy, Read, SideLoad, Update}
|
alias Ash.Actions.{Create, Destroy, Read, Update}
|
||||||
alias Ash.Error.Invalid.{InvalidPrimaryKey, NoSuchAction, NoSuchResource}
|
alias Ash.Error.Invalid.{InvalidPrimaryKey, NoSuchAction, NoSuchResource}
|
||||||
|
|
||||||
@global_opts [
|
@global_opts [
|
||||||
|
@ -54,24 +54,17 @@ defmodule Ash.Api do
|
||||||
@doc false
|
@doc false
|
||||||
def read_opts_schema, do: @read_opts_schema
|
def read_opts_schema, do: @read_opts_schema
|
||||||
|
|
||||||
@side_load_opts_schema merge_schemas([], @global_opts, "Global Options")
|
@load_opts_schema merge_schemas([], @global_opts, "Global Options")
|
||||||
|
|
||||||
@get_opts_schema [
|
@get_opts_schema [
|
||||||
side_load: [
|
load: [
|
||||||
type: :any,
|
type: :any,
|
||||||
doc:
|
doc: "Fields or relationships to load in the query. See `Ash.Query.load/2`"
|
||||||
"Side loads to include in the query, same as you would pass to `Ash.Query.side_load/2`"
|
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|> merge_schemas(@global_opts, "Global Options")
|
|> merge_schemas(@global_opts, "Global Options")
|
||||||
|
|
||||||
@shared_create_and_update_opts_schema [
|
@shared_create_and_update_opts_schema []
|
||||||
side_load: [
|
|
||||||
type: :any,
|
|
||||||
doc:
|
|
||||||
"Side loads to include in the query, same as you would pass to `Ash.Query.side_load/2`"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|> merge_schemas(@global_opts, "Global Options")
|
|> merge_schemas(@global_opts, "Global Options")
|
||||||
|
|
||||||
@create_opts_schema [
|
@create_opts_schema [
|
||||||
|
@ -135,23 +128,31 @@ defmodule Ash.Api do
|
||||||
{:ok, list(Ash.resource())} | {:error, Ash.error()}
|
{:ok, list(Ash.resource())} | {:error, Ash.error()}
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Side load on already fetched records. See `c:side_load/2` for more information.
|
Load fields or relationships on already fetched records. See `c:load/2` for more information.
|
||||||
|
|
||||||
#{NimbleOptions.docs(@side_load_opts_schema)}
|
#{NimbleOptions.docs(@load_opts_schema)}
|
||||||
"""
|
"""
|
||||||
@callback side_load!(resource :: Ash.resource(), params :: Keyword.t() | Ash.query()) ::
|
@callback load!(
|
||||||
list(Ash.resource()) | no_return
|
record_or_records :: Ash.record() | [Ash.record()],
|
||||||
|
params :: Keyword.t() | Ash.query()
|
||||||
|
) ::
|
||||||
|
Ash.record() | [Ash.record()] | no_return
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Side load on already fetched records.
|
Load fields or relationships on already fetched records.
|
||||||
|
|
||||||
Accepts a keyword list of side loads as they would be passed into `Ash.Query.side_load/2`
|
Accepts a list of non-loaded fields and loads them on the provided records or a query, in
|
||||||
or an `%Ash.Query{}`, in which case that query's side loads are used.
|
which case the loaded fields of the query are used. Relationship loads can be nested, for
|
||||||
|
example: `MyApi.load(record, [posts: [:comments]])`. See `Ash.Query.side_load/2` for more
|
||||||
|
information on specifically loading relationships.
|
||||||
|
|
||||||
#{NimbleOptions.docs(@side_load_opts_schema)}
|
#{NimbleOptions.docs(@load_opts_schema)}
|
||||||
"""
|
"""
|
||||||
@callback side_load(resource :: Ash.resource(), params :: Keyword.t() | Ash.query()) ::
|
@callback load(
|
||||||
{:ok, list(Ash.resource())} | {:error, Ash.error()}
|
record_or_records :: Ash.record() | [Ash.record()],
|
||||||
|
params :: Keyword.t() | Ash.query()
|
||||||
|
) ::
|
||||||
|
{:ok, Ash.record() | [Ash.record()]} | {:error, Ash.error()}
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Create a record. See `c:create/2` for more information.
|
Create a record. See `c:create/2` for more information.
|
||||||
|
@ -282,8 +283,8 @@ defmodule Ash.Api do
|
||||||
resource
|
resource
|
||||||
|> Ash.Query.new(api)
|
|> Ash.Query.new(api)
|
||||||
|> Ash.Query.filter(filter)
|
|> Ash.Query.filter(filter)
|
||||||
|> Ash.Query.side_load(opts[:side_load] || [])
|
|> Ash.Query.load(opts[:load] || [])
|
||||||
|> api.read(Keyword.delete(opts, :side_load))
|
|> api.read(Keyword.delete(opts, :load))
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, [single_result]} ->
|
{:ok, [single_result]} ->
|
||||||
{:ok, single_result}
|
{:ok, single_result}
|
||||||
|
@ -321,38 +322,38 @@ defmodule Ash.Api do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
@spec side_load!(
|
@spec load!(
|
||||||
Ash.api(),
|
Ash.api(),
|
||||||
Ash.record() | list(Ash.record()),
|
Ash.record() | list(Ash.record()),
|
||||||
Ash.query() | list(atom | {atom, list()}),
|
Ash.query() | list(atom | {atom, list()}),
|
||||||
Keyword.t()
|
Keyword.t()
|
||||||
) ::
|
) ::
|
||||||
list(Ash.record()) | Ash.record() | no_return
|
list(Ash.record()) | Ash.record() | no_return
|
||||||
def side_load!(api, data, query, opts \\ []) do
|
def load!(api, data, query, opts \\ []) do
|
||||||
opts = NimbleOptions.validate!(opts, @side_load_opts_schema)
|
opts = NimbleOptions.validate!(opts, @load_opts_schema)
|
||||||
|
|
||||||
api
|
api
|
||||||
|> side_load(data, query, opts)
|
|> load(data, query, opts)
|
||||||
|> unwrap_or_raise!()
|
|> unwrap_or_raise!()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
@spec side_load(Ash.api(), Ash.query(), Keyword.t()) ::
|
@spec load(Ash.api(), Ash.query(), Keyword.t()) ::
|
||||||
{:ok, list(Ash.resource())} | {:error, Ash.error()}
|
{:ok, list(Ash.resource())} | {:error, Ash.error()}
|
||||||
def side_load(api, data, query, opts \\ [])
|
def load(api, data, query, opts \\ [])
|
||||||
def side_load(_, [], _, _), do: {:ok, []}
|
def load(_, [], _, _), do: {:ok, []}
|
||||||
def side_load(_, nil, _, _), do: {:ok, nil}
|
def load(_, nil, _, _), do: {:ok, nil}
|
||||||
|
|
||||||
def side_load(api, data, query, opts) when not is_list(data) do
|
def load(api, data, query, opts) when not is_list(data) do
|
||||||
api
|
api
|
||||||
|> side_load(List.wrap(data), query, opts)
|
|> load(List.wrap(data), query, opts)
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, [data]} -> {:ok, data}
|
{:ok, [data]} -> {:ok, data}
|
||||||
{:error, error} -> {:error, error}
|
{:error, error} -> {:error, error}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def side_load(api, [%resource{} | _] = data, query, opts) do
|
def load(api, [%resource{} | _] = data, query, opts) do
|
||||||
query =
|
query =
|
||||||
case query do
|
case query do
|
||||||
%Ash.Query{} = query ->
|
%Ash.Query{} = query ->
|
||||||
|
@ -361,12 +362,13 @@ defmodule Ash.Api do
|
||||||
keyword ->
|
keyword ->
|
||||||
resource
|
resource
|
||||||
|> Ash.Query.new(api)
|
|> Ash.Query.new(api)
|
||||||
|> Ash.Query.side_load(keyword)
|
|> Ash.Query.load(keyword)
|
||||||
end
|
end
|
||||||
|
|
||||||
with %{valid?: true} <- query,
|
with %{valid?: true} <- query,
|
||||||
{:ok, opts} <- NimbleOptions.validate(opts, @side_load_opts_schema) do
|
{:ok, action} <- get_action(query.resource, opts, :read),
|
||||||
SideLoad.side_load(data, query, opts)
|
{:ok, opts} <- NimbleOptions.validate(opts, @load_opts_schema) do
|
||||||
|
Read.run(query, action, Keyword.put(opts, :initial_data, data))
|
||||||
else
|
else
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:error, error}
|
{:error, error}
|
||||||
|
@ -404,7 +406,7 @@ defmodule Ash.Api do
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
@spec create!(Ash.api(), Ash.changeset(), Keyword.t()) ::
|
@spec create!(Ash.api(), Ash.changeset(), Keyword.t()) ::
|
||||||
Ash.record() | {:error, Ash.error()}
|
Ash.record() | no_return
|
||||||
def create!(api, changeset, opts) do
|
def create!(api, changeset, opts) do
|
||||||
opts = NimbleOptions.validate!(opts, @create_opts_schema)
|
opts = NimbleOptions.validate!(opts, @create_opts_schema)
|
||||||
|
|
||||||
|
@ -415,7 +417,7 @@ defmodule Ash.Api do
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
@spec create(Ash.api(), Ash.changeset(), Keyword.t()) ::
|
@spec create(Ash.api(), Ash.changeset(), Keyword.t()) ::
|
||||||
{:ok, Ash.resource()} | {:error, Ash.error()}
|
{:ok, Ash.record()} | {:error, Ash.error()}
|
||||||
def create(api, changeset, opts) do
|
def create(api, changeset, opts) do
|
||||||
with {:ok, opts} <- NimbleOptions.validate(opts, @create_opts_schema),
|
with {:ok, opts} <- NimbleOptions.validate(opts, @create_opts_schema),
|
||||||
{:ok, resource} <- Ash.Api.resource(api, changeset.resource),
|
{:ok, resource} <- Ash.Api.resource(api, changeset.resource),
|
||||||
|
|
|
@ -36,13 +36,13 @@ defmodule Ash.Api.Interface do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def side_load!(data, query, opts \\ []) do
|
def load!(data, query, opts \\ []) do
|
||||||
Api.side_load!(__MODULE__, data, query, opts)
|
Api.load!(__MODULE__, data, query, opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def side_load(data, query, opts \\ []) do
|
def load(data, query, opts \\ []) do
|
||||||
case Api.side_load(__MODULE__, data, query, opts) do
|
case Api.load(__MODULE__, data, query, opts) do
|
||||||
{:ok, results} -> {:ok, results}
|
{:ok, results} -> {:ok, results}
|
||||||
{:error, error} -> {:error, List.wrap(error)}
|
{:error, error} -> {:error, List.wrap(error)}
|
||||||
end
|
end
|
||||||
|
|
|
@ -9,7 +9,7 @@ defmodule Ash.DataLayer do
|
||||||
"""
|
"""
|
||||||
@type feature() ::
|
@type feature() ::
|
||||||
:transact
|
:transact
|
||||||
| {:aggregate, Ash.aggregate_type()}
|
| {:aggregate, Ash.aggregate_kind()}
|
||||||
| :aggregate_filter
|
| :aggregate_filter
|
||||||
| :aggregate_sort
|
| :aggregate_sort
|
||||||
| :boolean_filter
|
| :boolean_filter
|
||||||
|
|
|
@ -61,8 +61,12 @@ defmodule Ash.Dsl.Entity do
|
||||||
opts,
|
opts,
|
||||||
nested_entities
|
nested_entities
|
||||||
) do
|
) do
|
||||||
with {:ok, opts} <- NimbleOptions.validate(opts, schema),
|
{before_validate_auto, after_validate_auto} =
|
||||||
opts <- Keyword.merge(opts, auto_set_fields || []),
|
Keyword.split(auto_set_fields || [], Keyword.keys(schema))
|
||||||
|
|
||||||
|
with {:ok, opts} <-
|
||||||
|
NimbleOptions.validate(Keyword.merge(opts, before_validate_auto), schema),
|
||||||
|
opts <- Keyword.merge(opts, after_validate_auto),
|
||||||
built <- struct(target, opts),
|
built <- struct(target, opts),
|
||||||
built <- struct(built, nested_entities),
|
built <- struct(built, nested_entities),
|
||||||
{:ok, built} <-
|
{:ok, built} <-
|
||||||
|
|
|
@ -18,7 +18,6 @@ defmodule Ash.Dsl.Transformer do
|
||||||
@callback transform(module, map) :: {:ok, map} | {:error, term} | :halt
|
@callback transform(module, map) :: {:ok, map} | {:error, term} | :halt
|
||||||
@callback before?(module) :: boolean
|
@callback before?(module) :: boolean
|
||||||
@callback after?(module) :: boolean
|
@callback after?(module) :: boolean
|
||||||
@callback compile_time_only? :: boolean
|
|
||||||
|
|
||||||
defmacro __using__(_) do
|
defmacro __using__(_) do
|
||||||
quote do
|
quote do
|
||||||
|
@ -26,29 +25,8 @@ defmodule Ash.Dsl.Transformer do
|
||||||
|
|
||||||
def before?(_), do: false
|
def before?(_), do: false
|
||||||
def after?(_), do: false
|
def after?(_), do: false
|
||||||
def compile_time_only?, do: false
|
|
||||||
|
|
||||||
defoverridable before?: 1, after?: 1, compile_time_only?: 0
|
defoverridable before?: 1, after?: 1
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def wait_for_transformer(resource, transformers, timeout \\ :timer.seconds(15), wait \\ 50)
|
|
||||||
|
|
||||||
def wait_for_transformer(resource, transformers, 0, _) do
|
|
||||||
raise "Timed out waiting for #{inspect(transformers)} for #{inspect(resource)}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def wait_for_transformer(resource, transformers, timeout, wait) do
|
|
||||||
transformers_run = :persistent_term.get({resource, :ash, :transformers}, [])
|
|
||||||
|
|
||||||
transformers = List.wrap(transformers)
|
|
||||||
|
|
||||||
if Enum.all?(transformers, &(&1 in transformers_run)) do
|
|
||||||
:ok
|
|
||||||
else
|
|
||||||
wait = min(timeout, wait)
|
|
||||||
:timer.sleep(wait)
|
|
||||||
wait_for_transformer(resource, transformers, timeout - wait, wait + 50)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
16
lib/ash/error/query/invalid_load.ex
Normal file
16
lib/ash/error/query/invalid_load.ex
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
defmodule Ash.Error.Query.InvalidLoad do
|
||||||
|
@moduledoc "Used when an invalid load is provided"
|
||||||
|
use Ash.Error
|
||||||
|
|
||||||
|
def_ash_error([:load], class: :invalid)
|
||||||
|
|
||||||
|
defimpl Ash.ErrorKind do
|
||||||
|
def id(_), do: Ecto.UUID.generate()
|
||||||
|
|
||||||
|
def code(_), do: "invalid_load"
|
||||||
|
|
||||||
|
def message(%{load: load}) do
|
||||||
|
"#{inspect(load)} is not a valid load"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -32,6 +32,8 @@ defmodule Ash.Filter do
|
||||||
|
|
||||||
defstruct [:resource, :expression]
|
defstruct [:resource, :expression]
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
def parse!(resource, statement, aggregates \\ %{}) do
|
def parse!(resource, statement, aggregates \\ %{}) do
|
||||||
case parse(resource, statement, aggregates) do
|
case parse(resource, statement, aggregates) do
|
||||||
{:ok, filter} ->
|
{:ok, filter} ->
|
||||||
|
|
|
@ -123,8 +123,11 @@ defmodule Ash.Filter.Runtime do
|
||||||
defp get_related(records, [key | rest]) when is_list(records) do
|
defp get_related(records, [key | rest]) when is_list(records) do
|
||||||
Enum.flat_map(records, fn record ->
|
Enum.flat_map(records, fn record ->
|
||||||
case Map.get(record, key) do
|
case Map.get(record, key) do
|
||||||
%Ecto.Association.NotLoaded{} -> []
|
%Ash.NotLoaded{type: :relationship} ->
|
||||||
value -> get_related(value, rest)
|
[]
|
||||||
|
|
||||||
|
value ->
|
||||||
|
get_related(value, rest)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
@ -133,7 +136,7 @@ defmodule Ash.Filter.Runtime do
|
||||||
Enum.all?(records, &loaded?(&1, path))
|
Enum.all?(records, &loaded?(&1, path))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp loaded?(%Ecto.Association.NotLoaded{}, _), do: false
|
defp loaded?(%Ash.NotLoaded{}, _), do: false
|
||||||
|
|
||||||
defp loaded?(_, []), do: true
|
defp loaded?(_, []), do: true
|
||||||
|
|
||||||
|
|
12
lib/ash/not_loaded.ex
Normal file
12
lib/ash/not_loaded.ex
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
defmodule Ash.NotLoaded do
|
||||||
|
@moduledoc "Used when an aggregate or relationship hasn't been loaded."
|
||||||
|
defstruct [:field, :type]
|
||||||
|
|
||||||
|
defimpl Inspect do
|
||||||
|
import Inspect.Algebra
|
||||||
|
|
||||||
|
def inspect(not_loaded, opts) do
|
||||||
|
concat(["#Ash.NotLoaded<", to_doc(not_loaded.type, opts), ">"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -7,11 +7,17 @@ defmodule Ash.Query.Aggregate do
|
||||||
:query,
|
:query,
|
||||||
:kind,
|
:kind,
|
||||||
:type,
|
:type,
|
||||||
:authorization_filter
|
:authorization_filter,
|
||||||
|
:load
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@kinds [:count]
|
||||||
|
|
||||||
@type t :: %__MODULE__{}
|
@type t :: %__MODULE__{}
|
||||||
@type type :: :count
|
@type kind :: unquote(Enum.reduce(@kinds, &{:|, [], [&1, &2]}))
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def kinds, do: @kinds
|
||||||
|
|
||||||
alias Ash.Actions.SideLoad
|
alias Ash.Actions.SideLoad
|
||||||
alias Ash.Engine.Request
|
alias Ash.Engine.Request
|
||||||
|
@ -55,10 +61,68 @@ defmodule Ash.Query.Aggregate do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp kind_to_type(:count), do: {:ok, Ash.Type.Integer}
|
@doc false
|
||||||
defp kind_to_type(kind), do: {:error, "Invalid aggregate kind: #{kind}"}
|
def kind_to_type(:count), do: {:ok, Ash.Type.Integer}
|
||||||
|
def kind_to_type(kind), do: {:error, "Invalid aggregate kind: #{kind}"}
|
||||||
|
|
||||||
def requests(initial_query, authorizing?) do
|
# def requests_with_initial_data(query, authorizing?) do
|
||||||
|
# query.aggregates
|
||||||
|
# |> Map.values()
|
||||||
|
# |> Enum.group_by(& &1.relationship_path)
|
||||||
|
# |> Enum.reduce({[], []}, fn {relationship_path, aggregates},
|
||||||
|
# {auth_requests, value_requests} ->
|
||||||
|
# related = Ash.Resource.related(initial_query.resource, relationship_path)
|
||||||
|
|
||||||
|
# relationship =
|
||||||
|
# Ash.Resource.relationship(
|
||||||
|
# initial_query.resource,
|
||||||
|
# List.first(relationship_path)
|
||||||
|
# )
|
||||||
|
|
||||||
|
# remaining_path = List.delete_at(relationship_path, 0)
|
||||||
|
|
||||||
|
# reverse_relationship =
|
||||||
|
# case SideLoad.reverse_relationship_path(relationship, remaining_path) do
|
||||||
|
# :error ->
|
||||||
|
# nil
|
||||||
|
|
||||||
|
# {:ok, reverse_relationship} ->
|
||||||
|
# reverse_relationship
|
||||||
|
# end
|
||||||
|
|
||||||
|
# auth_request =
|
||||||
|
# if authorizing? do
|
||||||
|
# auth_request(related, initial_query, reverse_relationship, relationship_path)
|
||||||
|
# else
|
||||||
|
# nil
|
||||||
|
# end
|
||||||
|
|
||||||
|
# new_auth_requests =
|
||||||
|
# if auth_request do
|
||||||
|
# [auth_request | auth_requests]
|
||||||
|
# else
|
||||||
|
# auth_requests
|
||||||
|
# end
|
||||||
|
|
||||||
|
# if reverse_relationship do
|
||||||
|
# request =
|
||||||
|
# value_request(
|
||||||
|
# initial_query,
|
||||||
|
# related,
|
||||||
|
# reverse_relationship,
|
||||||
|
# relationship_path,
|
||||||
|
# aggregates,
|
||||||
|
# auth_request
|
||||||
|
# )
|
||||||
|
|
||||||
|
# {new_auth_requests, [request | value_requests]}
|
||||||
|
# else
|
||||||
|
# raise "Unimplemented"
|
||||||
|
# end
|
||||||
|
# end)
|
||||||
|
# end
|
||||||
|
|
||||||
|
def requests(initial_query, can_be_in_query?, authorizing?) do
|
||||||
initial_query.aggregates
|
initial_query.aggregates
|
||||||
|> Map.values()
|
|> Map.values()
|
||||||
|> Enum.group_by(& &1.relationship_path)
|
|> Enum.group_by(& &1.relationship_path)
|
||||||
|
@ -77,26 +141,17 @@ defmodule Ash.Query.Aggregate do
|
||||||
{in_query?, reverse_relationship} =
|
{in_query?, reverse_relationship} =
|
||||||
case SideLoad.reverse_relationship_path(relationship, remaining_path) do
|
case SideLoad.reverse_relationship_path(relationship, remaining_path) do
|
||||||
:error ->
|
:error ->
|
||||||
{true, nil}
|
{can_be_in_query?, nil}
|
||||||
|
|
||||||
{:ok, reverse_relationship} ->
|
{:ok, reverse_relationship} ->
|
||||||
{any_aggregate_matching_path_used_in_query?(initial_query, relationship_path),
|
{can_be_in_query? &&
|
||||||
|
any_aggregate_matching_path_used_in_query?(initial_query, relationship_path),
|
||||||
reverse_relationship}
|
reverse_relationship}
|
||||||
end
|
end
|
||||||
|
|
||||||
auth_request =
|
auth_request =
|
||||||
if authorizing? do
|
if authorizing? do
|
||||||
Request.new(
|
auth_request(related, initial_query, reverse_relationship, relationship_path)
|
||||||
resource: related,
|
|
||||||
api: initial_query.api,
|
|
||||||
async?: false,
|
|
||||||
query: aggregate_query(related, reverse_relationship),
|
|
||||||
path: [:aggregate, relationship_path],
|
|
||||||
strict_check_only?: true,
|
|
||||||
action: Ash.Resource.primary_action!(related, :read),
|
|
||||||
name: "authorize aggregate: #{Enum.join(relationship_path, ".")}",
|
|
||||||
data: []
|
|
||||||
)
|
|
||||||
else
|
else
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
@ -126,6 +181,20 @@ defmodule Ash.Query.Aggregate do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp auth_request(related, initial_query, reverse_relationship, relationship_path) do
|
||||||
|
Request.new(
|
||||||
|
resource: related,
|
||||||
|
api: initial_query.api,
|
||||||
|
async?: false,
|
||||||
|
query: aggregate_query(related, reverse_relationship),
|
||||||
|
path: [:aggregate, relationship_path],
|
||||||
|
strict_check_only?: true,
|
||||||
|
action: Ash.Resource.primary_action!(related, :read),
|
||||||
|
name: "authorize aggregate: #{Enum.join(relationship_path, ".")}",
|
||||||
|
data: []
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
defp value_request(
|
defp value_request(
|
||||||
initial_query,
|
initial_query,
|
||||||
related,
|
related,
|
||||||
|
@ -154,13 +223,15 @@ defmodule Ash.Query.Aggregate do
|
||||||
Request.resolve(
|
Request.resolve(
|
||||||
deps,
|
deps,
|
||||||
fn data ->
|
fn data ->
|
||||||
if data.data.data == [] do
|
records = get_in(data, [:data, :data])
|
||||||
|
|
||||||
|
if records == [] do
|
||||||
{:ok, %{}}
|
{:ok, %{}}
|
||||||
else
|
else
|
||||||
initial_query = Ash.Query.unset(initial_query, [:filter, :sort, :aggregates])
|
initial_query = Ash.Query.unset(initial_query, [:filter, :sort, :aggregates])
|
||||||
|
|
||||||
query =
|
query =
|
||||||
case data.data.data do
|
case records do
|
||||||
[record] ->
|
[record] ->
|
||||||
Ash.Query.filter(
|
Ash.Query.filter(
|
||||||
initial_query,
|
initial_query,
|
||||||
|
@ -195,12 +266,27 @@ defmodule Ash.Query.Aggregate do
|
||||||
data_layer_query,
|
data_layer_query,
|
||||||
query.resource
|
query.resource
|
||||||
) do
|
) do
|
||||||
|
loaded_aggregates =
|
||||||
|
aggregates
|
||||||
|
|> Enum.map(& &1.load)
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|
||||||
|
all_aggregates = Enum.map(aggregates, & &1.name)
|
||||||
|
|
||||||
aggregate_values =
|
aggregate_values =
|
||||||
Enum.reduce(results, %{}, fn result, acc ->
|
Enum.reduce(results, %{}, fn result, acc ->
|
||||||
|
loaded_aggregate_values = Map.take(result, loaded_aggregates)
|
||||||
|
|
||||||
|
all_aggregate_values =
|
||||||
|
result.aggregates
|
||||||
|
|> Kernel.||(%{})
|
||||||
|
|> Map.take(all_aggregates)
|
||||||
|
|> Map.merge(loaded_aggregate_values)
|
||||||
|
|
||||||
Map.put(
|
Map.put(
|
||||||
acc,
|
acc,
|
||||||
Map.take(result, pkey),
|
Map.take(result, pkey),
|
||||||
Map.take(result.aggregates || %{}, Enum.map(aggregates, & &1.name))
|
all_aggregate_values
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
@ -235,9 +321,13 @@ defmodule Ash.Query.Aggregate do
|
||||||
fn data ->
|
fn data ->
|
||||||
data_query = data.data.query
|
data_query = data.data.query
|
||||||
|
|
||||||
|
if reverse_relationship do
|
||||||
filter = Ash.Filter.put_at_path(data_query.filter, reverse_relationship)
|
filter = Ash.Filter.put_at_path(data_query.filter, reverse_relationship)
|
||||||
|
|
||||||
{:ok, Ash.Query.filter(resource, filter)}
|
{:ok, Ash.Query.filter(resource, filter)}
|
||||||
|
else
|
||||||
|
{:ok, data_query}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
@ -70,6 +70,120 @@ defmodule Ash.Query do
|
||||||
|> set_data_layer_query()
|
|> set_data_layer_query()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec load(t(), atom | list(atom)) :: t()
|
||||||
|
def load(query, fields) when not is_list(fields) do
|
||||||
|
load(query, List.wrap(fields))
|
||||||
|
end
|
||||||
|
|
||||||
|
def load(query, fields) do
|
||||||
|
query = to_query(query)
|
||||||
|
|
||||||
|
Enum.reduce(fields, query, fn
|
||||||
|
{field, rest}, query ->
|
||||||
|
side_load(query, [{field, rest}])
|
||||||
|
|
||||||
|
field, query ->
|
||||||
|
do_load(query, field)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_load(query, field) do
|
||||||
|
cond do
|
||||||
|
Ash.Resource.attribute(query.resource, field) ->
|
||||||
|
query
|
||||||
|
|
||||||
|
Ash.Resource.relationship(query.resource, field) ->
|
||||||
|
side_load(query, field)
|
||||||
|
|
||||||
|
aggregate = Ash.Resource.aggregate(query.resource, field) ->
|
||||||
|
with %{valid?: true} = aggregate_query <-
|
||||||
|
build(query.resource, filter: aggregate.filter),
|
||||||
|
{:ok, query_aggregate} <-
|
||||||
|
Aggregate.new(
|
||||||
|
query.resource,
|
||||||
|
aggregate.name,
|
||||||
|
aggregate.kind,
|
||||||
|
aggregate.relationship_path,
|
||||||
|
aggregate_query
|
||||||
|
) do
|
||||||
|
query_aggregate = %{query_aggregate | load: field}
|
||||||
|
new_aggregates = Map.put(query.aggregates, aggregate.name, query_aggregate)
|
||||||
|
|
||||||
|
%{query | aggregates: new_aggregates}
|
||||||
|
else
|
||||||
|
%{errors: errors} ->
|
||||||
|
add_error(query, :aggregates, Ash.Error.to_ash_error(errors))
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
add_error(query, :aggregates, Ash.Error.to_ash_error(error))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def unload(query, fields) do
|
||||||
|
query = to_query(query)
|
||||||
|
|
||||||
|
Enum.reduce(fields, query, fn field, query ->
|
||||||
|
case field do
|
||||||
|
{field, rest} ->
|
||||||
|
new_side_loads = do_unload_side_load(query.side_load, {field, rest})
|
||||||
|
%{query | side_load: new_side_loads}
|
||||||
|
|
||||||
|
field ->
|
||||||
|
do_unload(query, field)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_unload(query, field) do
|
||||||
|
cond do
|
||||||
|
Ash.Resource.attribute(query.resource, field) ->
|
||||||
|
query
|
||||||
|
|
||||||
|
Ash.Resource.relationship(query.resource, field) ->
|
||||||
|
%{query | side_load: Keyword.delete(query.side_load, field)}
|
||||||
|
|
||||||
|
Ash.Resource.aggregate(query.resource, field) ->
|
||||||
|
new_aggregates =
|
||||||
|
Enum.reduce(query.aggregates, %{}, fn
|
||||||
|
{_field, %{load: ^field}}, acc ->
|
||||||
|
acc
|
||||||
|
|
||||||
|
{field, aggregate}, acc ->
|
||||||
|
Map.put(acc, field, aggregate)
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{query | aggregates: new_aggregates}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_unload_side_load(%__MODULE__{} = query, unload) do
|
||||||
|
%{query | side_load: do_unload_side_load(query.side_load, unload)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_unload_side_load(side_loads, {field, rest}) do
|
||||||
|
Enum.reduce(side_loads, [], fn
|
||||||
|
^field, acc ->
|
||||||
|
acc
|
||||||
|
|
||||||
|
{^field, value}, acc ->
|
||||||
|
new_value =
|
||||||
|
rest
|
||||||
|
|> List.wrap()
|
||||||
|
|> Enum.reduce(value, &do_unload_side_load(&2, &1))
|
||||||
|
|
||||||
|
[{field, new_value} | acc]
|
||||||
|
|
||||||
|
value, acc ->
|
||||||
|
[value | acc]
|
||||||
|
end)
|
||||||
|
|> Enum.reverse()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_unload_side_load(side_loads, field) do
|
||||||
|
do_unload_side_load(side_loads, {field, []})
|
||||||
|
end
|
||||||
|
|
||||||
@spec build(Ash.resource(), Ash.api() | nil, Keyword.t()) :: t()
|
@spec build(Ash.resource(), Ash.api() | nil, Keyword.t()) :: t()
|
||||||
def build(resource, api \\ nil, keyword) do
|
def build(resource, api \\ nil, keyword) do
|
||||||
Enum.reduce(keyword, new(resource, api), fn
|
Enum.reduce(keyword, new(resource, api), fn
|
||||||
|
@ -111,8 +225,13 @@ defmodule Ash.Query do
|
||||||
either a filter or a keyword list of options to supply to build a limiting query for that aggregate.
|
either a filter or a keyword list of options to supply to build a limiting query for that aggregate.
|
||||||
However, currently only filters are accepted.
|
However, currently only filters are accepted.
|
||||||
"""
|
"""
|
||||||
@spec aggregate(Ash.query(), atom(), Ash.aggregate_type(), atom | list(atom), Ash.query() | nil) ::
|
@spec aggregate(
|
||||||
Ash.Query.t()
|
t() | Ash.resource(),
|
||||||
|
atom(),
|
||||||
|
Ash.aggregate_kind(),
|
||||||
|
atom | list(atom),
|
||||||
|
Ash.query() | nil
|
||||||
|
) :: t()
|
||||||
def aggregate(query, name, type, relationship, agg_query \\ nil) do
|
def aggregate(query, name, type, relationship, agg_query \\ nil) do
|
||||||
query = to_query(query)
|
query = to_query(query)
|
||||||
relationship = List.wrap(relationship)
|
relationship = List.wrap(relationship)
|
||||||
|
@ -149,7 +268,8 @@ defmodule Ash.Query do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc "Limit the results returned from the query"
|
@doc "Limit the results returned from the query"
|
||||||
def limit(query, nil), do: query
|
@spec limit(t() | Ash.resource(), nil | integer()) :: t()
|
||||||
|
def limit(query, nil), do: to_query(query)
|
||||||
|
|
||||||
def limit(query, limit) when is_integer(limit) do
|
def limit(query, limit) when is_integer(limit) do
|
||||||
query
|
query
|
||||||
|
@ -163,7 +283,8 @@ defmodule Ash.Query do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc "Skip the first n records"
|
@doc "Skip the first n records"
|
||||||
def offset(query, nil), do: query
|
@spec offset(t() | Ash.resource(), nil | integer()) :: t()
|
||||||
|
def offset(query, nil), do: to_query(query)
|
||||||
|
|
||||||
def offset(query, offset) when is_integer(offset) do
|
def offset(query, offset) when is_integer(offset) do
|
||||||
query
|
query
|
||||||
|
@ -179,6 +300,7 @@ defmodule Ash.Query do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc "Side loads related entities"
|
@doc "Side loads related entities"
|
||||||
|
@spec side_load(t() | Ash.resource(), Ash.side_loads()) :: t()
|
||||||
def side_load(query, statement) do
|
def side_load(query, statement) do
|
||||||
query = to_query(query)
|
query = to_query(query)
|
||||||
|
|
||||||
|
@ -248,6 +370,7 @@ defmodule Ash.Query do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec filter(t() | Ash.resource(), nil | false | Ash.filter() | Keyword.t()) :: t()
|
||||||
def filter(query, nil), do: to_query(query)
|
def filter(query, nil), do: to_query(query)
|
||||||
|
|
||||||
def filter(query, %Ash.Filter{} = filter) do
|
def filter(query, %Ash.Filter{} = filter) do
|
||||||
|
@ -292,6 +415,7 @@ defmodule Ash.Query do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec sort(t() | Ash.resource(), Ash.sort()) :: t()
|
||||||
def sort(query, sorts) do
|
def sort(query, sorts) do
|
||||||
query = to_query(query)
|
query = to_query(query)
|
||||||
|
|
||||||
|
@ -308,11 +432,12 @@ defmodule Ash.Query do
|
||||||
|> set_data_layer_query()
|
|> set_data_layer_query()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec unset(Ash.resource() | t(), atom | [atom]) :: t()
|
||||||
def unset(query, keys) when is_list(keys) do
|
def unset(query, keys) when is_list(keys) do
|
||||||
keys
|
keys
|
||||||
|> Enum.reduce(query, fn key, query ->
|
|> Enum.reduce(query, fn key, query ->
|
||||||
if key in [:api, :resource] do
|
if key in [:api, :resource] do
|
||||||
query
|
to_query(query)
|
||||||
else
|
else
|
||||||
query
|
query
|
||||||
|> to_query()
|
|> to_query()
|
||||||
|
@ -324,7 +449,7 @@ defmodule Ash.Query do
|
||||||
|
|
||||||
def unset(query, key) do
|
def unset(query, key) do
|
||||||
if key in [:api, :resource] do
|
if key in [:api, :resource] do
|
||||||
query
|
to_query(query)
|
||||||
else
|
else
|
||||||
query
|
query
|
||||||
|> to_query()
|
|> to_query()
|
||||||
|
|
|
@ -2,7 +2,7 @@ defmodule Ash.Resource do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
A resource is a static definition of an entity in your system.
|
A resource is a static definition of an entity in your system.
|
||||||
|
|
||||||
Resource DSL documentation: `Ash.Dsl`
|
Resource DSL documentation: `Ash.Resource.Dsl`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Ash.Dsl.Extension
|
alias Ash.Dsl.Extension
|
||||||
|
@ -17,9 +17,9 @@ defmodule Ash.Resource do
|
||||||
|
|
||||||
extensions =
|
extensions =
|
||||||
if data_layer && Ash.implements_behaviour?(data_layer, Ash.Dsl.Extension) do
|
if data_layer && Ash.implements_behaviour?(data_layer, Ash.Dsl.Extension) do
|
||||||
[data_layer, Ash.Dsl]
|
[data_layer, Ash.Resource.Dsl]
|
||||||
else
|
else
|
||||||
[Ash.Dsl]
|
[Ash.Resource.Dsl]
|
||||||
end
|
end
|
||||||
|
|
||||||
authorizer_extensions =
|
authorizer_extensions =
|
||||||
|
@ -137,6 +137,17 @@ defmodule Ash.Resource do
|
||||||
|> Enum.find(&(&1.name == relationship_name))
|
|> Enum.find(&(&1.name == relationship_name))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec aggregates(Ash.resource()) :: list(Ash.relationship())
|
||||||
|
def aggregates(resource) do
|
||||||
|
Extension.get_entities(resource, [:aggregates])
|
||||||
|
end
|
||||||
|
|
||||||
|
def aggregate(resource, name) do
|
||||||
|
resource
|
||||||
|
|> aggregates()
|
||||||
|
|> Enum.find(&(&1.name == name))
|
||||||
|
end
|
||||||
|
|
||||||
@doc "Returns the primary action of the given type"
|
@doc "Returns the primary action of the given type"
|
||||||
@spec primary_action!(Ash.resource(), Ash.action_type()) :: Ash.action() | no_return
|
@spec primary_action!(Ash.resource(), Ash.action_type()) :: Ash.action() | no_return
|
||||||
def primary_action!(resource, type) do
|
def primary_action!(resource, type) do
|
||||||
|
|
40
lib/ash/resource/aggregate.ex
Normal file
40
lib/ash/resource/aggregate.ex
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
defmodule Ash.Resource.Aggregate do
|
||||||
|
@moduledoc "Represents a named aggregate on the resource that can be loaded"
|
||||||
|
defstruct [:name, :relationship_path, :filter, :kind, :query]
|
||||||
|
|
||||||
|
@schema [
|
||||||
|
name: [
|
||||||
|
type: :atom,
|
||||||
|
doc: "The field to place the aggregate in",
|
||||||
|
required: true
|
||||||
|
],
|
||||||
|
relationship_path: [
|
||||||
|
type: {:custom, __MODULE__, :relationship_path, []},
|
||||||
|
doc: "The relationship or relationship path to use for the aggregate",
|
||||||
|
required: true
|
||||||
|
],
|
||||||
|
kind: [
|
||||||
|
type: {:one_of, Ash.Query.Aggregate.kinds()},
|
||||||
|
doc: "The kind of the aggregate",
|
||||||
|
required: true
|
||||||
|
],
|
||||||
|
filter: [
|
||||||
|
type: :keyword_list,
|
||||||
|
doc: "A filter to apply to the aggregate",
|
||||||
|
default: []
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def schema, do: @schema
|
||||||
|
|
||||||
|
def relationship_path(value) do
|
||||||
|
value = List.wrap(value)
|
||||||
|
|
||||||
|
if Enum.all?(value, &is_atom/1) do
|
||||||
|
{:ok, value}
|
||||||
|
else
|
||||||
|
{:error, "relationship path must be atoms"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,5 +1,5 @@
|
||||||
defmodule Ash.Resource.Attribute do
|
defmodule Ash.Resource.Attribute do
|
||||||
@moduledoc false
|
@moduledoc "Represents an attribute on a resource"
|
||||||
|
|
||||||
defstruct [
|
defstruct [
|
||||||
:name,
|
:name,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
defmodule Ash.Dsl do
|
defmodule Ash.Resource.Dsl do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
The built in resource DSL. The four core DSL components of a resource are:
|
The built in resource DSL. The four core DSL components of a resource are:
|
||||||
|
|
||||||
|
@ -282,7 +282,34 @@ defmodule Ash.Dsl do
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@sections [@attributes, @relationships, @actions, @resource, @validations]
|
@count %Ash.Dsl.Entity{
|
||||||
|
name: :count,
|
||||||
|
describe: """
|
||||||
|
Declares a named aggregate on the resource
|
||||||
|
""",
|
||||||
|
examples: [
|
||||||
|
"count :assigned_ticket_count, :reported_tickets, filter: [active: true]"
|
||||||
|
],
|
||||||
|
target: Ash.Resource.Aggregate,
|
||||||
|
args: [:name, :relationship_path],
|
||||||
|
schema: Ash.Resource.Aggregate.schema(),
|
||||||
|
auto_set_fields: [kind: :count]
|
||||||
|
}
|
||||||
|
|
||||||
|
@aggregates %Ash.Dsl.Section{
|
||||||
|
name: :aggregates,
|
||||||
|
describe: """
|
||||||
|
Declare named aggregates on the resource.
|
||||||
|
|
||||||
|
These are aggregates that can be fetched only by name using `Ash.Query.fetch/2`.
|
||||||
|
They are also available as top level fields on the resource.
|
||||||
|
""",
|
||||||
|
entities: [
|
||||||
|
@count
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@sections [@attributes, @relationships, @actions, @resource, @validations, @aggregates]
|
||||||
|
|
||||||
@transformers [
|
@transformers [
|
||||||
Ash.Resource.Transformers.SetRelationshipSource,
|
Ash.Resource.Transformers.SetRelationshipSource,
|
||||||
|
@ -291,6 +318,7 @@ defmodule Ash.Dsl do
|
||||||
Ash.Resource.Transformers.CreateJoinRelationship,
|
Ash.Resource.Transformers.CreateJoinRelationship,
|
||||||
Ash.Resource.Transformers.CachePrimaryKey,
|
Ash.Resource.Transformers.CachePrimaryKey,
|
||||||
Ash.Resource.Transformers.SetPrimaryActions
|
Ash.Resource.Transformers.SetPrimaryActions
|
||||||
|
# Ash.Resource.Transformers.SetAggregateQueries
|
||||||
]
|
]
|
||||||
|
|
||||||
use Ash.Dsl.Extension,
|
use Ash.Dsl.Extension,
|
|
@ -8,6 +8,7 @@ defmodule Ash.Schema do
|
||||||
|
|
||||||
defmacro define_schema do
|
defmacro define_schema do
|
||||||
quote unquote: false do
|
quote unquote: false do
|
||||||
|
alias Ash.Query.Aggregate
|
||||||
use Ecto.Schema
|
use Ecto.Schema
|
||||||
@primary_key false
|
@primary_key false
|
||||||
|
|
||||||
|
@ -22,6 +23,13 @@ defmodule Ash.Schema do
|
||||||
end
|
end
|
||||||
|
|
||||||
field(:aggregates, :map, virtual: true, default: %{})
|
field(:aggregates, :map, virtual: true, default: %{})
|
||||||
|
|
||||||
|
for aggregate <- Ash.Resource.aggregates(__MODULE__) do
|
||||||
|
{:ok, type} = Aggregate.kind_to_type(aggregate.kind)
|
||||||
|
|
||||||
|
field(aggregate.name, Ash.Type.ecto_type(type), virtual: true)
|
||||||
|
end
|
||||||
|
|
||||||
relationships = Ash.Resource.relationships(__MODULE__)
|
relationships = Ash.Resource.relationships(__MODULE__)
|
||||||
|
|
||||||
for relationship <- Enum.filter(relationships, &(&1.type == :belongs_to)) do
|
for relationship <- Enum.filter(relationships, &(&1.type == :belongs_to)) do
|
||||||
|
@ -55,6 +63,21 @@ defmodule Ash.Schema do
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
for relationship <- relationships do
|
||||||
|
new_struct_fields =
|
||||||
|
Enum.reject(@struct_fields, fn {name, _} -> name == relationship.name end) ++
|
||||||
|
[{relationship.name, %Ash.NotLoaded{field: relationship.name, type: :relationship}}]
|
||||||
|
|
||||||
|
Module.delete_attribute(__MODULE__, :struct_fields)
|
||||||
|
|
||||||
|
Module.register_attribute(__MODULE__, :struct_fields, accumulate: true)
|
||||||
|
|
||||||
|
Enum.each(
|
||||||
|
Enum.reverse(new_struct_fields),
|
||||||
|
&Module.put_attribute(__MODULE__, :struct_fields, &1)
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,7 +7,7 @@ defmodule Ash.Resource.Transformers.BelongsToAttribute do
|
||||||
alias Ash.Dsl.Transformer
|
alias Ash.Dsl.Transformer
|
||||||
alias Ash.Error.Dsl.DslError
|
alias Ash.Error.Dsl.DslError
|
||||||
|
|
||||||
@extension Ash.Dsl
|
@extension Ash.Resource.Dsl
|
||||||
|
|
||||||
def transform(_resource, dsl_state) do
|
def transform(_resource, dsl_state) do
|
||||||
dsl_state
|
dsl_state
|
||||||
|
|
|
@ -9,7 +9,7 @@ defmodule Ash.Resource.Transformers.CreateJoinRelationship do
|
||||||
|
|
||||||
alias Ash.Dsl.Transformer
|
alias Ash.Dsl.Transformer
|
||||||
|
|
||||||
@extension Ash.Dsl
|
@extension Ash.Resource.Dsl
|
||||||
|
|
||||||
def transform(_resource, dsl_state) do
|
def transform(_resource, dsl_state) do
|
||||||
dsl_state
|
dsl_state
|
||||||
|
|
|
@ -44,7 +44,7 @@ defmodule Mix.Tasks.Ash.Formatter do
|
||||||
|
|
||||||
contents =
|
contents =
|
||||||
@formatter_exs_template
|
@formatter_exs_template
|
||||||
|> String.replace("__replace_me__", inspect(locals_without_parens))
|
|> String.replace("__replace_me__", inspect(locals_without_parens, limit: :infinity))
|
||||||
|> Code.format_string!()
|
|> Code.format_string!()
|
||||||
|
|
||||||
contents_with_newline = [contents, "\n"]
|
contents_with_newline = [contents, "\n"]
|
||||||
|
|
4
mix.exs
4
mix.exs
|
@ -63,8 +63,8 @@ defmodule Ash.MixProject do
|
||||||
Ash.Dsl.Extension,
|
Ash.Dsl.Extension,
|
||||||
Ash.Dsl.Section
|
Ash.Dsl.Section
|
||||||
],
|
],
|
||||||
|
"resource dsl": ~r/Ash.Resource.Dsl/,
|
||||||
"resource dsl transformers": ~r/Ash.Resource.Transformers/,
|
"resource dsl transformers": ~r/Ash.Resource.Transformers/,
|
||||||
"resource dsl": ~r/Ash.Dsl/,
|
|
||||||
"api dsl transformers": ~r/Ash.Api.Transformers/,
|
"api dsl transformers": ~r/Ash.Api.Transformers/,
|
||||||
"api dsl": ~r/Ash.Api.Dsl/,
|
"api dsl": ~r/Ash.Api.Dsl/,
|
||||||
"filter predicates": ~r/Ash.Filter.Predicate/,
|
"filter predicates": ~r/Ash.Filter.Predicate/,
|
||||||
|
@ -113,7 +113,7 @@ defmodule Ash.MixProject do
|
||||||
sobelow: "sobelow --skip",
|
sobelow: "sobelow --skip",
|
||||||
credo: "credo --strict",
|
credo: "credo --strict",
|
||||||
"ash.formatter":
|
"ash.formatter":
|
||||||
"ash.formatter --extensions Ash.Dsl,Ash.Api.Dsl,Ash.DataLayer.Ets,Ash.DataLayer.Mnesia"
|
"ash.formatter --extensions Ash.Resource.Dsl,Ash.Api.Dsl,Ash.DataLayer.Ets,Ash.DataLayer.Mnesia"
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -468,7 +468,7 @@ defmodule Ash.Test.Actions.UpdateTest do
|
||||||
|> replace_relationship(:author, author2)
|
|> replace_relationship(:author, author2)
|
||||||
|> Api.update!()
|
|> Api.update!()
|
||||||
|
|
||||||
assert Api.get!(Author, author2.id, side_load: [:posts]).posts == [Api.get!(Post, post.id)]
|
assert Api.get!(Author, author2.id, load: [:posts]).posts == [Api.get!(Post, post.id)]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it responds with the relationship field filled in" do
|
test "it responds with the relationship field filled in" do
|
||||||
|
|
Loading…
Reference in a new issue