mirror of
https://github.com/ash-project/ash.git
synced 2024-09-20 05:23:03 +12:00
improvement: Add get_by
option to read actions. (#530)
This commit is contained in:
parent
da7ef311e8
commit
171967d6de
4 changed files with 348 additions and 21 deletions
|
@ -1,30 +1,37 @@
|
||||||
defmodule Ash.Resource.Actions.Read do
|
defmodule Ash.Resource.Actions.Read do
|
||||||
@moduledoc "Represents a read action on a resource."
|
@moduledoc "Represents a read action on a resource."
|
||||||
|
|
||||||
defstruct [
|
defstruct arguments: [],
|
||||||
:name,
|
description: nil,
|
||||||
:pagination,
|
filter: nil,
|
||||||
:primary?,
|
get_by: nil,
|
||||||
:filter,
|
get?: nil,
|
||||||
:description,
|
manual: nil,
|
||||||
:get?,
|
metadata: [],
|
||||||
:manual,
|
modify_query: nil,
|
||||||
modify_query: nil,
|
name: nil,
|
||||||
transaction?: false,
|
pagination: nil,
|
||||||
arguments: [],
|
preparations: [],
|
||||||
preparations: [],
|
primary?: nil,
|
||||||
touches_resources: [],
|
touches_resources: [],
|
||||||
metadata: [],
|
transaction?: false,
|
||||||
type: :read
|
type: :read
|
||||||
]
|
|
||||||
|
|
||||||
@type t :: %__MODULE__{
|
@type t :: %__MODULE__{
|
||||||
type: :read,
|
arguments: [Ash.Resource.Actions.Argument.t()],
|
||||||
name: atom,
|
description: String.t(),
|
||||||
|
filter: any,
|
||||||
|
get_by: nil | [atom],
|
||||||
|
get?: nil | boolean,
|
||||||
manual: atom | {atom, Keyword.t()} | nil,
|
manual: atom | {atom, Keyword.t()} | nil,
|
||||||
|
metadata: [Ash.Resource.Actions.Metadata.t()],
|
||||||
|
modify_query: nil | mfa,
|
||||||
|
name: atom,
|
||||||
|
pagination: any,
|
||||||
primary?: boolean,
|
primary?: boolean,
|
||||||
touches_resources: list(atom),
|
touches_resources: [atom],
|
||||||
description: String.t()
|
transaction?: boolean,
|
||||||
|
type: :read
|
||||||
}
|
}
|
||||||
|
|
||||||
import Ash.Resource.Actions.SharedOptions
|
import Ash.Resource.Actions.SharedOptions
|
||||||
|
@ -69,6 +76,17 @@ defmodule Ash.Resource.Actions.Read do
|
||||||
|
|
||||||
Here be dragons.
|
Here be dragons.
|
||||||
"""
|
"""
|
||||||
|
],
|
||||||
|
get_by: [
|
||||||
|
type: {:or, [:atom, {:list, :atom}]},
|
||||||
|
default: nil,
|
||||||
|
doc: """
|
||||||
|
A helper to automatically generate a "get by X" action.
|
||||||
|
|
||||||
|
Using this option will set `get?` to true, add arguments
|
||||||
|
for each of the specified fields, and add a filter to the
|
||||||
|
underlying query for each of the arguments.
|
||||||
|
"""
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
@global_opts,
|
@global_opts,
|
||||||
|
|
|
@ -1287,7 +1287,8 @@ defmodule Ash.Resource.Dsl do
|
||||||
Ash.Resource.Transformers.ValidateRelationshipAttributes,
|
Ash.Resource.Transformers.ValidateRelationshipAttributes,
|
||||||
Ash.Resource.Transformers.ValidateEagerIdentities,
|
Ash.Resource.Transformers.ValidateEagerIdentities,
|
||||||
Ash.Resource.Transformers.ValidateAggregatesSupported,
|
Ash.Resource.Transformers.ValidateAggregatesSupported,
|
||||||
Ash.Resource.Transformers.ValidateAccept
|
Ash.Resource.Transformers.ValidateAccept,
|
||||||
|
Ash.Resource.Transformers.GetByReadActions
|
||||||
]
|
]
|
||||||
|
|
||||||
@verifiers [
|
@verifiers [
|
||||||
|
|
241
lib/ash/resource/transformers/get_by_read_actions.ex
Normal file
241
lib/ash/resource/transformers/get_by_read_actions.ex
Normal file
|
@ -0,0 +1,241 @@
|
||||||
|
defmodule Ash.Resource.Transformers.GetByReadActions do
|
||||||
|
@moduledoc """
|
||||||
|
Transform any read actions which contain a `get_by` option.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Spark.Dsl.Transformer
|
||||||
|
|
||||||
|
alias Ash.{Resource, Type}
|
||||||
|
alias Spark.{Dsl, Dsl.Transformer, Error.DslError}
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec before?(module) :: boolean
|
||||||
|
def before?(_), do: false
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec after?(module) :: boolean
|
||||||
|
def after?(_), do: false
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec transform(Dsl.t()) :: {:ok, Dsl.t()} | {:error, DslError.t()}
|
||||||
|
def transform(dsl_state) do
|
||||||
|
dsl_state
|
||||||
|
|> Transformer.get_entities([:actions])
|
||||||
|
|> Stream.filter(&(&1.type == :read))
|
||||||
|
|> Stream.reject(&(is_nil(&1.get_by) || &1.get_by == []))
|
||||||
|
|> Enum.reduce_while({:ok, dsl_state}, fn action, {:ok, dsl_state} ->
|
||||||
|
action = %{action | get_by: List.wrap(action.get_by)}
|
||||||
|
|
||||||
|
with :ok <- validate_get_by_value(dsl_state, action),
|
||||||
|
:ok <- validate_existing_arguments(dsl_state, action),
|
||||||
|
{:ok, action} <- transform_action(dsl_state, action) do
|
||||||
|
{:cont,
|
||||||
|
{:ok,
|
||||||
|
Transformer.replace_entity(
|
||||||
|
dsl_state,
|
||||||
|
[:actions],
|
||||||
|
action,
|
||||||
|
&(&1.name == action.name)
|
||||||
|
)}}
|
||||||
|
else
|
||||||
|
{:error, reason} -> {:halt, {:error, reason}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp transform_action(dsl_state, action) do
|
||||||
|
import Ash.Filter.TemplateHelpers, only: [arg: 1]
|
||||||
|
import Ash.Expr
|
||||||
|
|
||||||
|
action =
|
||||||
|
action.get_by
|
||||||
|
|> Enum.reduce(%{action | get?: true}, fn field, action ->
|
||||||
|
type = type_for_entity(dsl_state, field)
|
||||||
|
|
||||||
|
arguments =
|
||||||
|
if Enum.any?(action.arguments, &(&1.name == field)) do
|
||||||
|
action.arguments
|
||||||
|
else
|
||||||
|
[
|
||||||
|
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||||
|
name: field,
|
||||||
|
type: type
|
||||||
|
)
|
||||||
|
| action.arguments
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
filter =
|
||||||
|
case action.filter do
|
||||||
|
nil -> expr(ref(^field) == ^arg(field))
|
||||||
|
filter -> where(^filter, ref(^field) == ^arg(field))
|
||||||
|
end
|
||||||
|
|
||||||
|
%{action | arguments: arguments, filter: filter}
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, action}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp type_for_entity(dsl_state, field) do
|
||||||
|
[]
|
||||||
|
|> Stream.concat(Transformer.get_entities(dsl_state, [:attributes]))
|
||||||
|
|> Stream.concat(Transformer.get_entities(dsl_state, [:calculations]))
|
||||||
|
|> Stream.concat(Transformer.get_entities(dsl_state, [:aggregates]))
|
||||||
|
|> Enum.find(&(&1.name == field))
|
||||||
|
|> case do
|
||||||
|
aggregate when is_struct(aggregate, Resource.Aggregate) ->
|
||||||
|
{:ok, type} = Resource.Info.aggregate_type(dsl_state, aggregate)
|
||||||
|
Type.get_type(type)
|
||||||
|
|
||||||
|
other ->
|
||||||
|
Type.get_type(other.type)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_get_by_value(dsl_state, action) do
|
||||||
|
attributes = map_entities(dsl_state, [:attributes], & &1.filterable?)
|
||||||
|
calculations = map_entities(dsl_state, [:calculations], & &1.filterable?)
|
||||||
|
aggregates = map_entities(dsl_state, [:aggregates], & &1.filterable?)
|
||||||
|
|
||||||
|
action.get_by
|
||||||
|
|> Enum.reduce_while(:ok, fn get_by, _ ->
|
||||||
|
cond do
|
||||||
|
Map.has_key?(attributes, get_by) ->
|
||||||
|
if Map.get(attributes, get_by),
|
||||||
|
do: {:cont, :ok},
|
||||||
|
else: {:halt, is_not_filterable_error(dsl_state, action, :attribute, get_by)}
|
||||||
|
|
||||||
|
Map.has_key?(calculations, get_by) ->
|
||||||
|
if Map.get(calculations, get_by),
|
||||||
|
do: {:cont, :ok},
|
||||||
|
else: {:halt, is_not_filterable_error(dsl_state, action, :calculation, get_by)}
|
||||||
|
|
||||||
|
Map.has_key?(aggregates, get_by) ->
|
||||||
|
if Map.get(aggregates, get_by),
|
||||||
|
do: {:cont, :ok},
|
||||||
|
else: {:halt, is_not_filterable_error(dsl_state, action, :aggregate, get_by)}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{:halt,
|
||||||
|
{:error,
|
||||||
|
dsl_error(
|
||||||
|
dsl_state,
|
||||||
|
[:actions, :read, action.name, :get_by],
|
||||||
|
"`#{inspect(get_by)}` is not a valid attribute, calculation or aggregate"
|
||||||
|
)}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_existing_arguments(_dsl_state, action) when action.arguments == [], do: :ok
|
||||||
|
|
||||||
|
defp validate_existing_arguments(dsl_state, action) do
|
||||||
|
attributes = map_entities(dsl_state, [:attributes], &Type.get_type(&1.type))
|
||||||
|
calculations = map_entities(dsl_state, [:calculations], &Type.get_type(&1.type))
|
||||||
|
|
||||||
|
aggregates =
|
||||||
|
map_entities(dsl_state, [:aggregates], fn aggregate ->
|
||||||
|
case Resource.Info.aggregate_type(dsl_state, aggregate) do
|
||||||
|
{:ok, type} -> Type.get_type(type)
|
||||||
|
{:error, _reason} -> nil
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
action.arguments
|
||||||
|
|> Stream.filter(&Enum.member?(action.get_by, &1))
|
||||||
|
|> Enum.reduce_while(:ok, fn argument, _ ->
|
||||||
|
argument_type = Type.get_type(argument.type)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
Map.has_key?(attributes, argument.name) ->
|
||||||
|
attribute_type = Map.get(attributes, argument.name)
|
||||||
|
|
||||||
|
if argument_type == attribute_type,
|
||||||
|
do: {:cont, :ok},
|
||||||
|
else:
|
||||||
|
{:halt,
|
||||||
|
types_do_not_match_error(
|
||||||
|
dsl_state,
|
||||||
|
action.name,
|
||||||
|
argument.name,
|
||||||
|
argument_type,
|
||||||
|
attribute_type,
|
||||||
|
:attribute
|
||||||
|
)}
|
||||||
|
|
||||||
|
Map.has_key?(calculations, argument.name) ->
|
||||||
|
calculation_type = Map.get(calculations, argument.name)
|
||||||
|
|
||||||
|
if argument_type == calculation_type,
|
||||||
|
do: {:cont, :ok},
|
||||||
|
else:
|
||||||
|
{:halt,
|
||||||
|
types_do_not_match_error(
|
||||||
|
dsl_state,
|
||||||
|
action.name,
|
||||||
|
argument.name,
|
||||||
|
argument_type,
|
||||||
|
calculation_type,
|
||||||
|
:calculation
|
||||||
|
)}
|
||||||
|
|
||||||
|
Map.has_key?(aggregates, argument.name) ->
|
||||||
|
aggregate_type = Map.get(aggregates, argument.name)
|
||||||
|
|
||||||
|
if argument_type == aggregate_type,
|
||||||
|
do: {:cont, :ok},
|
||||||
|
else:
|
||||||
|
{:halt,
|
||||||
|
types_do_not_match_error(
|
||||||
|
dsl_state,
|
||||||
|
action.name,
|
||||||
|
argument.name,
|
||||||
|
argument_type,
|
||||||
|
aggregate_type,
|
||||||
|
:aggregate
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp types_do_not_match_error(
|
||||||
|
dsl_state,
|
||||||
|
action_name,
|
||||||
|
argument_name,
|
||||||
|
argument_type,
|
||||||
|
property_type,
|
||||||
|
property_type_type
|
||||||
|
) do
|
||||||
|
{:error,
|
||||||
|
dsl_error(
|
||||||
|
dsl_state,
|
||||||
|
[:actions, :read, action_name, :arguments, argument_name],
|
||||||
|
"Type `#{inspect(argument_type)}` does not match the corresponding #{property_type_type} type (`#{inspect(property_type)}`)"
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp map_entities(dsl_state, path, mapper) when is_function(mapper, 1) do
|
||||||
|
dsl_state
|
||||||
|
|> Transformer.get_entities(path)
|
||||||
|
|> Stream.map(&{&1.name, mapper.(&1)})
|
||||||
|
|> Map.new()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp is_not_filterable_error(dsl_state, action, type, name) do
|
||||||
|
{:error,
|
||||||
|
dsl_error(
|
||||||
|
dsl_state,
|
||||||
|
[:actions, :read, action.name, :get_by],
|
||||||
|
"The #{type} `#{inspect(name)}` is not filterable, so cannot be used in a `get_by` action"
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp dsl_error(dsl_state, path, message) do
|
||||||
|
DslError.exception(
|
||||||
|
module: Transformer.get_persisted(dsl_state, :module),
|
||||||
|
path: path,
|
||||||
|
message: message
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
|
@ -60,6 +60,14 @@ defmodule Ash.Test.Actions.ReadTest do
|
||||||
read :read_with_after_action do
|
read :read_with_after_action do
|
||||||
prepare PostPreparation
|
prepare PostPreparation
|
||||||
end
|
end
|
||||||
|
|
||||||
|
read :get_by_id do
|
||||||
|
get_by :id
|
||||||
|
end
|
||||||
|
|
||||||
|
read :get_by_id_and_uuid do
|
||||||
|
get_by [:id, :uuid]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
attributes do
|
attributes do
|
||||||
|
@ -553,4 +561,63 @@ defmodule Ash.Test.Actions.ReadTest do
|
||||||
|> strip_metadata()
|
|> strip_metadata()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "get_by with only a single field" do
|
||||||
|
setup do
|
||||||
|
post =
|
||||||
|
Enum.map(0..2, fn _ ->
|
||||||
|
Post
|
||||||
|
|> new(%{title: "test", contents: "yeet"})
|
||||||
|
|> Api.create!()
|
||||||
|
|> strip_metadata()
|
||||||
|
end)
|
||||||
|
|> Enum.random()
|
||||||
|
|
||||||
|
%{post_id: post.id}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it succeeds when the record exists", %{post_id: post_id} do
|
||||||
|
assert {:ok, %{id: ^post_id}} =
|
||||||
|
Post |> Ash.Query.for_read(:get_by_id, %{id: post_id}) |> Api.read_one()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it fails when the record does not exist" do
|
||||||
|
assert {:ok, nil} =
|
||||||
|
Post
|
||||||
|
|> Ash.Query.for_read(:get_by_id, %{id: Ash.UUID.generate()})
|
||||||
|
|> Api.read_one()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_by with multiple fields" do
|
||||||
|
setup do
|
||||||
|
post =
|
||||||
|
Enum.map(0..2, fn _ ->
|
||||||
|
Post
|
||||||
|
|> new(%{title: "test", contents: "yeet"})
|
||||||
|
|> Api.create!()
|
||||||
|
|> strip_metadata()
|
||||||
|
end)
|
||||||
|
|> Enum.random()
|
||||||
|
|
||||||
|
%{post_id: post.id, post_uuid: post.uuid}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it succeeds when the record exists", %{post_id: post_id, post_uuid: post_uuid} do
|
||||||
|
assert {:ok, %{id: ^post_id, uuid: ^post_uuid}} =
|
||||||
|
Post
|
||||||
|
|> Ash.Query.for_read(:get_by_id_and_uuid, %{id: post_id, uuid: post_uuid})
|
||||||
|
|> Api.read_one()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it fails when the record does not exist" do
|
||||||
|
assert {:ok, nil} =
|
||||||
|
Post
|
||||||
|
|> Ash.Query.for_read(:get_by_id_and_uuid, %{
|
||||||
|
id: Ash.UUID.generate(),
|
||||||
|
uuid: Ash.UUID.generate()
|
||||||
|
})
|
||||||
|
|> Api.read_one()
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue