improvement: add get_by and get_by_identity to code interface

improvement: compile time validations for managed relationships
This commit is contained in:
Zach Daniel 2021-10-19 21:39:30 -04:00
parent f1a51532b0
commit 5e6442c3e4
7 changed files with 200 additions and 12 deletions

View file

@ -53,6 +53,8 @@ locals_without_parens = [
first: 4,
generated?: 1,
get?: 1,
get_by: 1,
get_by_identity: 1,
global?: 1,
has_many: 2,
has_many: 3,

View file

@ -23,9 +23,26 @@ defmodule Ash.CodeInterface do
for interface <- Ash.Resource.Info.interfaces(resource) do
action = Ash.CodeInterface.require_action(resource, interface)
args = interface.args || []
filter_keys =
if action.type == :read do
if interface.get_by_identity do
Ash.Resource.Info.identity(resource, interface.get_by_identity).keys
else
if interface.get_by do
interface.get_by
end
end
end
args = List.wrap(filter_keys) ++ (interface.args || [])
arg_vars = Enum.map(args, &{&1, [], Elixir})
unless Enum.uniq(args) == args do
raise """
Arguments #{inspect(args)} for #{interface.name} are not unique!
"""
end
doc = """
#{action.description || "Calls the #{action.name} action on the #{inspect(resource)} resource."}
@ -62,13 +79,27 @@ defmodule Ash.CodeInterface do
end)
query =
opts[:query]
|> Kernel.||(unquote(resource))
|> Ash.Query.for_read(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
if unquote(filter_keys) do
require Ash.Query
{filters, input} = Map.split(input, unquote(filter_keys))
opts[:query]
|> Kernel.||(unquote(resource))
|> Ash.Query.for_read(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
|> Ash.Query.filter(filters)
else
opts[:query]
|> Kernel.||(unquote(resource))
|> Ash.Query.for_read(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
end
if unquote(interface.get?) do
query

View file

@ -745,6 +745,7 @@ defmodule Ash.Resource.Dsl do
],
target: Ash.Resource.Interface,
schema: Ash.Resource.Interface.schema(),
transform: {Ash.Resource.Interface, :transform, []},
args: [:name]
}
@ -1067,6 +1068,7 @@ defmodule Ash.Resource.Dsl do
]
@transformers [
Ash.Resource.Transformers.ValidateManagedRelationshipOpts,
Ash.Resource.Transformers.RequireUniqueActionNames,
Ash.Resource.Transformers.SetRelationshipSource,
Ash.Resource.Transformers.BelongsToAttribute,

View file

@ -129,6 +129,14 @@ defmodule Ash.Resource.Info do
Extension.get_entities(resource, [:identities])
end
@doc "Get an identity by name from the resource"
@spec identity(Ash.Resource.t(), atom) :: Ash.Resource.Identity.t() | nil
def identity(resource, name) do
resource
|> identities()
|> Enum.find(&(&1.name == name))
end
@doc "A list of authorizers to be used when accessing"
@spec authorizers(Ash.Resource.t()) :: [module]
def authorizers(resource) do
@ -268,12 +276,12 @@ defmodule Ash.Resource.Info do
resource,
[:multitenancy],
:parse_attribute,
{__MODULE__, :identity, []}
{__MODULE__, :_identity, []}
)
end
@doc false
def identity(x), do: x
def _identity(x), do: x
@spec multitenancy_global?(Ash.Resource.t()) :: atom | nil
def multitenancy_global?(resource) do

View file

@ -2,7 +2,7 @@ defmodule Ash.Resource.Interface do
@moduledoc """
Represents a function in a resource's code interface
"""
defstruct [:name, :action, :args, :get?]
defstruct [:name, :action, :args, :get?, :get_by, :get_by_identity]
@type t :: %__MODULE__{}
@ -19,6 +19,14 @@ defmodule Ash.Resource.Interface do
end
end
def transform(definition) do
if definition.get_by || definition.get_by_identity do
{:ok, %{definition | get?: true}}
else
{:ok, definition}
end
end
def interface_options(action_type) do
[
tenant: [
@ -89,7 +97,29 @@ defmodule Ash.Resource.Interface do
doc: """
Only relevant for read actions. Expects to only receive a single result from a read action.
For example, `get_user_by_email`.
The action should return a single result based on any arguments provided. To make it so that the function
takes a specific field, and filters on that field, use `get_by` instead.
Useful for creating functions like `get_user_by_email` that map to an action that has an `:email` argument.
"""
],
get_by: [
type: {:list, :atom},
doc: """
Only relevant for read actions. Takes a list of fields and adds those fields as arguments, which will then be used to filter.
Automatically sets `get?` to `true`.
The action should return a single result based on any arguments provided. To make it so that the function
takes a specific field, and filters on that field, use `get_by` instead. When combined, `get_by` takes precedence.
Useful for creating functions like `get_user_by_id` that map to a basic read action.
"""
],
get_by_identity: [
type: :atom,
doc: """
Only relevant for read actions. Takes an identity, and gets its field list, performing the same logic as `get_by` once it has the list of fields.
"""
]
]

View file

@ -0,0 +1,105 @@
defmodule Ash.Resource.Transformers.ValidateManagedRelationshipOpts do
@moduledoc """
Confirms that all action types declared on a resource are supported by its data layer
"""
use Ash.Dsl.Transformer
alias Ash.Changeset.ManagedRelationshipHelpers
alias Ash.Dsl.Transformer
def after_compile?, do: true
def transform(resource, dsl_state) do
dsl_state
|> Transformer.get_entities([:actions])
|> Enum.reject(&(&1.type == :read))
|> Enum.each(fn action ->
action.changes
|> Enum.filter(
&match?(%Ash.Resource.Change{change: {Ash.Resource.Change.ManageRelationship, _}}, &1)
)
|> Enum.each(fn %Ash.Resource.Change{change: {_, opts}} ->
unless Enum.find(action.arguments, &(&1.name == opts[:argument])) do
raise Ash.Error.Dsl.DslError,
module: resource,
path:
[
:actions,
action.type,
action.name,
:change,
:manage_relationship
] ++ Enum.uniq([opts[:argument], opts[:relationship]]),
message: "Action #{action.name} has no argument `#{inspect(opts[:argument])}`."
end
relationship =
Ash.Resource.Info.relationship(resource, opts[:relationship]) ||
raise Ash.Error.Dsl.DslError,
module: resource,
path:
[
:actions,
action.type,
action.name,
:change,
:manage_relationship
] ++ Enum.uniq([opts[:argument], opts[:relationship]]),
message: "No such relationship #{opts[:relationship]} exists."
if ensure_compiled?(relationship) do
try do
manage_opts =
if opts[:opts][:type] do
defaults = Ash.Changeset.manage_relationship_opts(opts[:opts][:type])
Enum.reduce(defaults, Ash.Changeset.manage_relationship_schema(), fn {key, value},
manage_opts ->
Ash.OptionsHelpers.set_default!(manage_opts, key, value)
end)
else
Ash.Changeset.manage_relationship_schema()
end
opts = Ash.OptionsHelpers.validate!(opts[:opts], manage_opts)
ManagedRelationshipHelpers.sanitize_opts(relationship, opts)
rescue
e ->
reraise Ash.Error.Dsl.DslError,
[
module: resource,
path:
[
:actions,
action.type,
action.name,
:change,
:manage_relationship
] ++ Enum.uniq([opts[:argument], opts[:relationship]]),
message: """
The following error was raised when validating options provided to manage_relationship.
#{Exception.format(:error, e, __STACKTRACE__)}
"""
],
__STACKTRACE__
end
end
end)
end)
{:ok, dsl_state}
end
defp ensure_compiled?(%Ash.Resource.Relationships.ManyToMany{
through: through,
destination: destination
}) do
Code.ensure_loaded?(through) && Code.ensure_loaded?(destination)
end
defp ensure_compiled?(%{destination: destination}) do
Code.ensure_loaded?(destination)
end
end

View file

@ -14,6 +14,7 @@ defmodule Ash.Test.CodeInterfaceTest do
define_for Ash.Test.CodeInterfaceTest.Api
define :get_user, action: :read, get?: true, args: [:id]
define :read_users, action: :read
define :get_by_id, action: :read, get_by: [:id]
end
actions do
@ -58,4 +59,13 @@ defmodule Ash.Test.CodeInterfaceTest do
User.get_user!(Ash.UUID.generate())
end
end
test "get_by! adds the proper arguments and filters" do
user =
User
|> Ash.Changeset.for_create(:create, %{first_name: "ted", last_name: "Danson"})
|> Api.create!()
assert User.get_by_id!(user.id).id == user.id
end
end