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
|
||||
@moduledoc "Represents a read action on a resource."
|
||||
|
||||
defstruct [
|
||||
:name,
|
||||
:pagination,
|
||||
:primary?,
|
||||
:filter,
|
||||
:description,
|
||||
:get?,
|
||||
:manual,
|
||||
modify_query: nil,
|
||||
transaction?: false,
|
||||
arguments: [],
|
||||
preparations: [],
|
||||
touches_resources: [],
|
||||
metadata: [],
|
||||
type: :read
|
||||
]
|
||||
defstruct arguments: [],
|
||||
description: nil,
|
||||
filter: nil,
|
||||
get_by: nil,
|
||||
get?: nil,
|
||||
manual: nil,
|
||||
metadata: [],
|
||||
modify_query: nil,
|
||||
name: nil,
|
||||
pagination: nil,
|
||||
preparations: [],
|
||||
primary?: nil,
|
||||
touches_resources: [],
|
||||
transaction?: false,
|
||||
type: :read
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
type: :read,
|
||||
name: atom,
|
||||
arguments: [Ash.Resource.Actions.Argument.t()],
|
||||
description: String.t(),
|
||||
filter: any,
|
||||
get_by: nil | [atom],
|
||||
get?: nil | boolean,
|
||||
manual: atom | {atom, Keyword.t()} | nil,
|
||||
metadata: [Ash.Resource.Actions.Metadata.t()],
|
||||
modify_query: nil | mfa,
|
||||
name: atom,
|
||||
pagination: any,
|
||||
primary?: boolean,
|
||||
touches_resources: list(atom),
|
||||
description: String.t()
|
||||
touches_resources: [atom],
|
||||
transaction?: boolean,
|
||||
type: :read
|
||||
}
|
||||
|
||||
import Ash.Resource.Actions.SharedOptions
|
||||
|
@ -69,6 +76,17 @@ defmodule Ash.Resource.Actions.Read do
|
|||
|
||||
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,
|
||||
|
|
|
@ -1287,7 +1287,8 @@ defmodule Ash.Resource.Dsl do
|
|||
Ash.Resource.Transformers.ValidateRelationshipAttributes,
|
||||
Ash.Resource.Transformers.ValidateEagerIdentities,
|
||||
Ash.Resource.Transformers.ValidateAggregatesSupported,
|
||||
Ash.Resource.Transformers.ValidateAccept
|
||||
Ash.Resource.Transformers.ValidateAccept,
|
||||
Ash.Resource.Transformers.GetByReadActions
|
||||
]
|
||||
|
||||
@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
|
||||
prepare PostPreparation
|
||||
end
|
||||
|
||||
read :get_by_id do
|
||||
get_by :id
|
||||
end
|
||||
|
||||
read :get_by_id_and_uuid do
|
||||
get_by [:id, :uuid]
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
|
@ -553,4 +561,63 @@ defmodule Ash.Test.Actions.ReadTest do
|
|||
|> strip_metadata()
|
||||
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
|
||||
|
|
Loading…
Reference in a new issue