diff --git a/.formatter.exs b/.formatter.exs index ce3ad1c8..55ca57f0 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -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, diff --git a/lib/ash/code_interface.ex b/lib/ash/code_interface.ex index 29ca8f71..52bba2a8 100644 --- a/lib/ash/code_interface.ex +++ b/lib/ash/code_interface.ex @@ -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 diff --git a/lib/ash/resource/dsl.ex b/lib/ash/resource/dsl.ex index 9d516f19..f11579a4 100644 --- a/lib/ash/resource/dsl.ex +++ b/lib/ash/resource/dsl.ex @@ -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, diff --git a/lib/ash/resource/info.ex b/lib/ash/resource/info.ex index 236ddea6..bfe56857 100644 --- a/lib/ash/resource/info.ex +++ b/lib/ash/resource/info.ex @@ -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 diff --git a/lib/ash/resource/interface.ex b/lib/ash/resource/interface.ex index 5a42e66a..25864048 100644 --- a/lib/ash/resource/interface.ex +++ b/lib/ash/resource/interface.ex @@ -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. """ ] ] diff --git a/lib/ash/resource/transformers/validate_manage_relationship_opts.ex b/lib/ash/resource/transformers/validate_manage_relationship_opts.ex new file mode 100644 index 00000000..3256649a --- /dev/null +++ b/lib/ash/resource/transformers/validate_manage_relationship_opts.ex @@ -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 diff --git a/test/code_interface_test.exs b/test/code_interface_test.exs index 0c3b79e7..77cd6ee0 100644 --- a/test/code_interface_test.exs +++ b/test/code_interface_test.exs @@ -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