diff --git a/lib/ash.ex b/lib/ash.ex index fef2446d..608d9af0 100644 --- a/lib/ash.ex +++ b/lib/ash.ex @@ -2,11 +2,9 @@ defmodule Ash do @moduledoc """ The primary interface for interrogating apis and resources. - This is not the code level interface for a resource. Instead, call functions - on an `Api` module that contains those resources. This is for retrieving - resource/api configurations. + These are tools for interrogating resources to derive behavior based on their + configuration. This is how all of the behavior of Ash is ultimately configured. """ - alias Ash.Error alias Ash.Resource.Actions.{Create, Destroy, Read, Update} alias Ash.Resource.Relationships.{BelongsTo, HasMany, HasOne, ManyToMany} @@ -32,77 +30,31 @@ defmodule Ash do @type query :: Ash.Query.t() @type actor :: Ash.record() - defmacro partial_resource(do: body) do - quote do - defmacro __using__(_) do - body = unquote(body) - - quote do - unquote(body) - end - end - end - end - - def ash_error?(value) do - !!Ash.Error.impl_for(value) - end - - def to_ash_error(values) when is_list(values) do - values = - Enum.map(values, fn value -> - if ash_error?(value) do - value - else - Error.Unknown.exception(error: values) - end - end) - - Error.choose_error(values) - end - - def to_ash_error(value) do - to_ash_error([value]) - end - + @doc "A short description of the resource, to be included in autogenerated documentation" + @spec describe(resource()) :: String.t() def describe(resource) do resource.describe() end + @doc "A list of authorizers to be used when accessing the resource" + @spec authorizers(resource()) :: [module] def authorizers(resource) do resource.authorizers() end - @spec resource_module?(module) :: boolean - def resource_module?(module) do - :attributes - |> module.module_info() - |> Keyword.get(:behaviour, []) - |> Enum.any?(&(&1 == Ash.Resource)) - end - - @spec data_layer_can?(resource(), Ash.DataLayer.feature()) :: boolean - def data_layer_can?(resource, feature) do - data_layer = data_layer(resource) - - data_layer && data_layer.can?(resource, feature) - end - - @spec data_layer_filters(resource) :: map - def data_layer_filters(resource) do - Ash.DataLayer.custom_filters(resource) - end - + @doc "A list of resource modules for a given API" @spec resources(api) :: list(resource()) def resources(api) do api.resources() end + @doc "A list of field names corresponding to the primary key of a resource" @spec primary_key(resource()) :: list(atom) def primary_key(resource) do resource.primary_key() end + @doc "Gets a relationship by name from the resource" @spec relationship(resource(), atom() | String.t()) :: relationship() | nil def relationship(resource, relationship_name) when is_bitstring(relationship_name) do Enum.find(resource.relationships(), &(to_string(&1.name) == relationship_name)) @@ -112,11 +64,13 @@ defmodule Ash do Enum.find(resource.relationships(), &(&1.name == relationship_name)) end + @doc "A list of relationships on the resource" @spec relationships(resource()) :: list(relationship()) def relationships(resource) do resource.relationships() end + @doc false def primary_action!(resource, type) do case primary_action(resource, type) do nil -> raise "Required primary #{type} action for #{inspect(resource)}" @@ -124,6 +78,7 @@ defmodule Ash do end end + @doc "Returns the primary action of a given type for a resource" @spec primary_action(resource(), atom()) :: action() | nil def primary_action(resource, type) do resource @@ -135,16 +90,19 @@ defmodule Ash do end end + @doc "Returns the action with the matching name and type on the resource" @spec action(resource(), atom(), atom()) :: action() | nil def action(resource, name, type) do Enum.find(resource.actions(), &(&1.name == name && &1.type == type)) end + @doc "A list of all actions on the resource" @spec actions(resource()) :: list(action()) def actions(resource) do resource.actions() end + @doc "Get an attribute name from the resource" @spec attribute(resource(), String.t() | atom) :: attribute() | nil def attribute(resource, name) when is_bitstring(name) do Enum.find(resource.attributes, &(to_string(&1.name) == name)) @@ -154,23 +112,41 @@ defmodule Ash do Enum.find(resource.attributes, &(&1.name == name)) end + @doc "A list of all attributes on the resource" @spec attributes(resource()) :: list(attribute()) def attributes(resource) do resource.attributes() end + @doc "The name of the resource, e.g 'posts'" @spec name(resource()) :: String.t() def name(resource) do resource.name() end + @doc "The type of the resource, e.g 'post'" @spec type(resource()) :: String.t() def type(resource) do resource.type() end + @doc "The data layer of the resource, or nil if it does not have one" @spec data_layer(resource()) :: data_layer() def data_layer(resource) do resource.data_layer() end + + @doc false + @spec data_layer_can?(resource(), Ash.DataLayer.feature()) :: boolean + def data_layer_can?(resource, feature) do + data_layer = data_layer(resource) + + data_layer && Ash.DataLayer.can?(feature, resource) + end + + @doc false + @spec data_layer_filters(resource) :: map + def data_layer_filters(resource) do + Ash.DataLayer.custom_filters(resource) + end end diff --git a/lib/ash/actions/relationships.ex b/lib/ash/actions/relationships.ex index ad4cba66..7c4b3272 100644 --- a/lib/ash/actions/relationships.ex +++ b/lib/ash/actions/relationships.ex @@ -227,7 +227,7 @@ defmodule Ash.Actions.Relationships do !is_map(data) -> validate_map_replace(relationship, data) - Map.get(data, :__struct__) && Ash.resource_module?(data.__struct__) -> + Map.get(data, :__struct__) -> validate_struct_replace(relationship, data, action_type) true -> diff --git a/lib/ash/data_layer/ets.ex b/lib/ash/data_layer/ets.ex index 32d96791..1bed4c28 100644 --- a/lib/ash/data_layer/ets.ex +++ b/lib/ash/data_layer/ets.ex @@ -9,9 +9,12 @@ defmodule Ash.DataLayer.Ets do alias Ash.Filter.{And, Eq, In, NotEq, NotIn, Or} + @callback ets_private?() :: boolean + defmacro __using__(opts) do quote bind_quoted: [opts: opts] do @data_layer Ash.DataLayer.Ets + @behaviour Ash.DataLayer.Ets @ets_private? Keyword.get(opts, :private?, false) @@ -21,6 +24,7 @@ defmodule Ash.DataLayer.Ets do end end + @spec private?(Ash.resource()) :: boolean def private?(resource) do resource.ets_private?() end @@ -31,7 +35,6 @@ defmodule Ash.DataLayer.Ets do end @impl true - def can?(resource, :async_engine) do not private?(resource) end diff --git a/lib/ash/engine/engine.ex b/lib/ash/engine/engine.ex index 673e6e38..f88debe9 100644 --- a/lib/ash/engine/engine.ex +++ b/lib/ash/engine/engine.ex @@ -336,7 +336,7 @@ defmodule Ash.Engine do end defp to_ash_error(error) do - if Ash.ash_error?(error) do + if Ash.Error.ash_error?(error) do error else Unknown.exception(error: error) diff --git a/lib/ash/engine/runner.ex b/lib/ash/engine/runner.ex index ec17e4e4..ba71146a 100644 --- a/lib/ash/engine/runner.ex +++ b/lib/ash/engine/runner.ex @@ -422,7 +422,7 @@ defmodule Ash.Engine.Runner do end defp to_ash_error(error) do - if Ash.ash_error?(error) do + if Ash.Error.ash_error?(error) do error else Unknown.exception(error: error) diff --git a/lib/ash/error.ex b/lib/ash/error.ex index fc29d6cf..f190df50 100644 --- a/lib/ash/error.ex +++ b/lib/ash/error.ex @@ -21,6 +21,27 @@ defmodule Ash.Error do @error_class_indices @error_classes |> Enum.with_index() |> Enum.into(%{}) + def ash_error?(value) do + !!impl_for(value) + end + + def to_ash_error(values) when is_list(values) do + values = + Enum.map(values, fn value -> + if ash_error?(value) do + value + else + Error.Unknown.exception(error: values) + end + end) + + Error.choose_error(values) + end + + def to_ash_error(value) do + to_ash_error([value]) + end + def choose_error(errors) do [error | _other_errors] = Enum.sort_by(errors, &Map.get(@error_class_indices, &1.class)) diff --git a/lib/ash/resource.ex b/lib/ash/resource.ex index b4cf9a5a..596a2d97 100644 --- a/lib/ash/resource.ex +++ b/lib/ash/resource.ex @@ -18,14 +18,36 @@ defmodule Ash.Resource do Resource DSL documentation: `Ash.Resource.DSL` + The following options apply to `use Ash.Resource, [...]` #{NimbleOptions.docs(@resource_opts_schema)} + For more information on the resource DSL, see `Ash.Resource.DSL` + Note: *Do not* call the functions on a resource, as in `MyResource.type()` as this is a *private* API and can change at any time. Instead, use the `Ash` module, for example: `Ash.type(MyResource)` """ + @doc "The name of the resource, e.g 'posts'" + @callback name() :: String.t() + @doc "The type of the resource, e.g 'post'" + @callback type() :: String.t() + @doc "A list of attribute names that make up the primary key, e.g [:class, :group]" @callback primary_key() :: [atom] + @doc "A list of relationships to other resources" + @callback relationships() :: [Ash.relationship()] + @doc "A list of actions available for the resource" + @callback actions() :: [Ash.action()] + @doc "A list of attributes on the resource" + @callback attributes() :: [Ash.attribute()] + @doc "A list of extensions implemented by the resource" + @callback extensions() :: [module] + @doc "The data_layer in use by the resource, or nil if there is not one" + @callback data_layer() :: module | nil + @doc "A description of the resource, to be showed in generated documentation" + @callback describe() :: String.t() + @doc "A list of authorizers to be used when accessing the resource" + @callback authorizers() :: [module] defmacro __using__(opts) do quote do diff --git a/lib/ash/type/type.ex b/lib/ash/type/type.ex index 7bc2fac7..4039ad5d 100644 --- a/lib/ash/type/type.ex +++ b/lib/ash/type/type.ex @@ -49,12 +49,13 @@ defmodule Ash.Type do @spec supports_filter?(Ash.resource(), t(), Ash.DataLayer.filter_type(), Ash.data_layer()) :: boolean - def supports_filter?(resource, type, filter_type, data_layer) when type in @builtin_names do - data_layer.can?(resource, {:filter, filter_type}) and filter_type in @builtins[type][:filters] + def supports_filter?(resource, type, filter_type, _data_layer) when type in @builtin_names do + Ash.data_layer_can?(resource, {:filter, filter_type}) and + filter_type in @builtins[type][:filters] end def supports_filter?(resource, type, filter_type, data_layer) do - data_layer.can?(resource, {:filter, filter_type}) and + Ash.data_layer_can?(resource, {:filter, filter_type}) and filter_type in type.supported_filter_types(data_layer) end