improvement: Add get_by option to read actions. (#530)

This commit is contained in:
James Harton 2023-03-21 18:03:06 +13:00 committed by GitHub
parent da7ef311e8
commit 171967d6de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 348 additions and 21 deletions

View file

@ -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,

View file

@ -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 [

View 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

View file

@ -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