defmodule Ash.Resource do
@moduledoc """
A resource is a static definition of an entity in your system.
[Resource DSL documentation](dsl-ash-resource.html)
"""
@type t :: module
@type record :: struct()
use Spark.Dsl,
single_extension_kinds: [:data_layer],
many_extension_kinds: [
:authorizers,
:notifiers
],
default_extensions: [
data_layer: Ash.DataLayer.Simple,
extensions: [Ash.Resource.Dsl]
],
extension_kind_types: [
authorizers: {:wrap_list, {:behaviour, Ash.Authorizer}},
data_layer: {:behaviour, Ash.DataLayer},
notifiers: {:wrap_list, {:behaviour, Ash.Notifier}}
],
opt_schema: [
simple_notifiers: [
type: {:list, {:behaviour, Ash.Notifier}},
doc: "Notifiers with no DSL."
],
validate_domain_inclusion?: [
type: :boolean,
doc: "Whether or not to validate that this resource is included in a domain.",
default: true
],
domain: [
type: {:behaviour, Ash.Domain},
doc:
"The domain to use when interacting with this resource. Also sets defaults for various options that ask for a domain."
],
embed_nil_values?: [
type: :boolean,
default: true,
doc:
"Whether or not to include keys with `nil` values in an embedded representation. Has no effect unless resource is an embedded resource."
]
]
@doc false
@impl Spark.Dsl
def init(opts) do
if opts[:data_layer] == :embedded do
{:ok,
opts
|> Keyword.put(:data_layer, Ash.DataLayer.Simple)
|> Keyword.put(:embedded?, true)}
else
{:ok, opts}
end
end
@impl true
def verify(module, opts) do
if Application.get_env(:ash, :validate_domain_resource_inclusion?, true) &&
Keyword.get(opts, :validate_domain_inclusion?, true) &&
!Ash.Resource.Info.embedded?(module) &&
Code.ensure_loaded?(Mix.Project) do
otp_app = Mix.Project.config()[:app]
domains =
Application.get_env(otp_app, :ash_domains, [])
domains =
if domain = Ash.Resource.Info.domain(module) do
[domain | domains]
else
domains
end
contained_in_domain =
if is_nil(domain) || Ash.Domain.Info.allow_unregistered?(domain) do
true
else
domains
|> Enum.flat_map(&Ash.Domain.Info.resources/1)
|> Enum.any?(&(&1 == module))
end
if !contained_in_domain do
IO.warn("""
Resource #{inspect(module)} is not present in any known Ash.Domain module.
Domain modules checked: #{inspect(domains)}
We check the following configuration for domain modules:
config :#{otp_app}, ash_domains: #{inspect(domains)}
To resolve this warning, do one of the following.
1. Add the resource to one of your configured domain modules.
2. Add the option `validate_domain_inclusion?: false` to `use Ash.Resource`
3. Configure all resources not to warn, with `config :ash, :validate_domain_resource_inclusion?, false`
""")
end
end
end
@doc false
@impl Spark.Dsl
def handle_opts(opts) do
quote bind_quoted: [
opts: opts,
embedded?: opts[:embedded?],
domain: opts[:domain],
has_domain?: Keyword.has_key?(opts, :domain),
embed_nil_values?: opts[:embed_nil_values?]
] do
@persist {:simple_notifiers, List.wrap(opts[:simple_notifiers])}
unless embedded? || has_domain? do
IO.warn("""
Configuration Error:
`domain` option missing for #{inspect(__MODULE__)}
If you wish to make a resource compatible with multiple domains, set the domain to `nil` explicitly.
Example configuration:
use Ash.Resource, #{String.trim_trailing(String.trim_leading(inspect([{:domain, YourDomain} | opts], pretty: true), "["), "]")}
""")
end
if domain do
@persist {:domain, domain}
end
if embedded? do
@persist {:embedded?, true}
require Ash.EmbeddableType
Ash.EmbeddableType.define_embeddable_type(embed_nil_values?: embed_nil_values?)
end
end
end
@doc false
# sobelow_skip ["DOS.StringToAtom"]
@impl Spark.Dsl
def handle_before_compile(_opts) do
quote do
require Ash.Schema
Ash.Schema.define_schema()
@all_arguments __MODULE__
|> Ash.Resource.Info.actions()
|> Enum.flat_map(& &1.arguments)
|> Enum.map(& &1.name)
|> Enum.uniq()
@arguments_by_action __MODULE__
|> Ash.Resource.Info.actions()
|> Map.new(fn action ->
{action.name, Enum.map(action.arguments, & &1.name)}
end)
@all_attributes __MODULE__
|> Ash.Resource.Info.public_attributes()
|> Enum.map(& &1.name)
|> Enum.uniq()
if AshPolicyAuthorizer.Authorizer in @extensions do
raise """
AshPolicyAuthorizer has been deprecated and is now built into Ash core.
To use it, replace `authorizers: [AshPolicyAuthorizer.Authorizer]` with `authorizers: [Ash.Policy.Authorizer]`
"""
end
if Ash.Resource.Info.define_interface?(__MODULE__) do
if domain =
Ash.Resource.Info.code_interface_domain(__MODULE__) ||
Ash.Resource.Info.domain(__MODULE__) do
if domain == __MODULE__ do
raise "code_interface.domain should be set to a Domain module, not the resource."
end
require Ash.CodeInterface
Ash.CodeInterface.define_interface(domain, __MODULE__)
end
end
@default_short_name __MODULE__
|> Module.split()
|> List.last()
|> Macro.underscore()
|> String.to_atom()
def default_short_name do
@default_short_name
end
@primary_key_with_types __MODULE__
|> Ash.Resource.Info.attributes()
|> Enum.filter(& &1.primary_key?)
|> Enum.map(&{&1.name, &1.type})
@primary_key @primary_key_with_types |> Enum.map(&elem(&1, 0))
if !Enum.empty?(@primary_key) do
if Ash.Resource.Info.primary_key_simple_equality?(__MODULE__) do
def primary_key_matches?(left, right) do
left_taken = Map.take(left, @primary_key)
left_taken == Map.take(right, @primary_key) && Enum.all?(Map.values(left_taken))
end
else
case @primary_key_with_types do
[{field, type}] ->
@pkey_field field
@pkey_type type
def primary_key_matches?(left, right) when not is_nil(left) and not is_nil(right) do
Ash.Type.equal?(
@pkey_type,
Map.fetch!(left, @pkey_field),
Map.fetch!(right, @pkey_field)
)
end
def primary_key_matches?(_left, _right), do: false
_ ->
def primary_key_matches?(left, right) do
Enum.all?(@primary_key_with_types, fn {name, type} ->
with {:ok, left_value} when not is_nil(left_value) <- Map.fetch(left, name),
{:ok, right_value} when not is_nil(right_value) <- Map.fetch(right, name) do
Ash.Type.equal?(type, left_value, right_value)
else
_ ->
false
end
end)
end
end
end
end
@doc """
Validates that the keys in the provided input are valid for at least one action on the resource.
Raises a KeyError error at compile time if not. This exists because generally a struct should only ever
be created by Ash as a result of a successful action. You should not be creating records manually in code,
e.g `%MyResource{value: 1, value: 2}`. Generally that is fine, but often with embedded resources it is nice
to be able to validate the keys that are being provided, e.g
```elixir
Resource
|> Ash.Changeset.for_create(:create, %{embedded: EmbeddedResource.input(foo: 1, bar: 2)})
|> Ash.create()
```
"""
@spec input(values :: map | Keyword.t()) :: map | no_return
def input(opts) do
Map.new(opts, fn {key, value} ->
if key in @all_arguments || key in @all_attributes do
{key, value}
else
raise KeyError, key: key
end
end)
end
@doc """
Same as `input/1`, except restricts the keys to values accepted by the action provided.
"""
@spec input(values :: map | Keyword.t(), action :: atom) :: map | no_return
def input(opts, action) do
case Map.fetch(@arguments_by_action, action) do
:error ->
raise ArgumentError, message: "No such action #{inspect(action)}"
{:ok, args} ->
action = Ash.Resource.Info.action(__MODULE__, action)
Map.new(opts, fn {key, value} ->
if key in action.accept do
{key, value}
else
raise KeyError, key: key
end
end)
end
end
end
end
@spec set_metadata(Ash.Resource.record(), map) :: Ash.Resource.record()
def set_metadata(record, map) do
%{record | __metadata__: Ash.Helpers.deep_merge_maps(record.__metadata__, map)}
end
@doc false
def set_meta(%{__meta__: _} = struct, meta) do
%{struct | __meta__: meta}
end
def set_meta(struct, _), do: struct
@spec put_metadata(Ash.Resource.record(), atom, term) :: Ash.Resource.record()
def put_metadata(record, key, term) do
set_metadata(record, %{key => term})
end
@doc "Sets a list of loaded key or paths to a key back to their original unloaded stated"
@spec unload_many(
nil | list(Ash.Resource.record()) | Ash.Resource.record() | Ash.Page.page(),
list(atom) | list(list(atom))
) ::
nil | list(Ash.Resource.record()) | Ash.Resource.record() | Ash.Page.page()
def unload_many(data, paths) do
Enum.reduce(paths, data, &unload(&2, &1))
end
@doc "Sets a loaded key or path to a key back to its original unloaded stated"
@spec unload(
nil | list(Ash.Resource.record()) | Ash.Resource.record() | Ash.Page.page(),
atom | list(atom)
) ::
nil | list(Ash.Resource.record()) | Ash.Resource.record() | Ash.Page.page()
def unload(nil, _), do: nil
def unload(%struct{results: results} = page, path)
when struct in [Ash.Page.Keyset, Ash.Page.Offset] do
%{page | results: unload(results, path)}
end
def unload(records, path) when is_list(records) do
Enum.map(records, &unload(&1, path))
end
def unload(record, [path]) do
unload(record, path)
end
def unload(record, [key | rest]) do
Map.update!(record, key, &unload(&1, rest))
end
def unload(%struct{} = record, key) when is_atom(key) do
Map.put(record, key, Map.get(struct.__struct__(), key))
end
def unload(other, _), do: other
@doc """
Returns true if the load or path to load has been loaded
## Options
- `lists`: set to `:any` to have this return true if any record in a list that appears has the value loaded. Default is `:all`.
- `unknown`: set to `true` to have unknown paths (like nil values or non-resources) return true. Defaults to `false`
- `strict?`: set to `true` to return false if a calculation with arguments is being checked
"""
@spec loaded?(
nil | list(Ash.Resource.record()) | Ash.Resource.record() | Ash.Page.page(),
atom | Ash.Query.Calculation.t() | Ash.Query.Aggregate.t() | list(atom),
opts :: Keyword.t()
) ::
boolean
def loaded?(data, path, opts \\ [])
def loaded?(records, path, opts) when not is_list(path) do
loaded?(records, List.wrap(path), opts)
end
def loaded?(%Ash.NotLoaded{}, _, _opts), do: false
def loaded?(_, [], _opts), do: true
# We actually just can't tell here, so we say no
def loaded?(nil, _, opts), do: Keyword.get(opts, :unknown, false)
def loaded?(%page{results: results}, path, opts)
when page in [Ash.Page.Keyset, Ash.Page.Offset] do
loaded?(results, path, opts)
end
def loaded?(records, path, opts) when is_list(records) do
case Keyword.get(opts, :lists, :all) do
:all ->
Enum.all?(records, &loaded?(&1, path, opts))
:any ->
Enum.any?(records, &loaded?(&1, path, opts))
end
end
def loaded?(%resource{} = record, [%Ash.Query.Calculation{} = calculation | rest], opts) do
if calculation.calc_name do
ignored_via_strict? =
if opts[:strict?] do
resource_calculation = Ash.Resource.Info.calculation(resource, calculation.calc_name)
resource_calculation && Enum.any?(resource_calculation.arguments)
else
false
end
# we can't say for sure if the original arguments provided
# were the same as these, so this is always false
if ignored_via_strict? do
false
else
if calculation.load do
loaded_on_type?(
Map.get(record, calculation.load),
rest,
calculation.type,
calculation.constraints,
opts
)
else
case Map.fetch(record.calculations, calculation.name) do
{:ok, value} ->
loaded_on_type?(
value,
rest,
calculation.type,
calculation.constraints,
opts
)
:error ->
false
end
end
end
else
if calculation.load do
loaded_on_type?(
Map.get(record, calculation.load),
rest,
calculation.type,
calculation.constraints,
opts
)
else
case Map.fetch(record.calculations, calculation.name) do
{:ok, value} ->
loaded_on_type?(
value,
rest,
calculation.type,
calculation.constraints,
opts
)
:error ->
false
end
end
end
end
def loaded?(record, [%Ash.Query.Aggregate{} = aggregate | rest], opts) do
if aggregate.load do
loaded_on_type?(
Map.get(record, aggregate.load),
rest,
aggregate.type,
aggregate.constraints,
opts
)
else
case Map.fetch(record.aggregates, aggregate.name) do
{:ok, value} ->
loaded_on_type?(
value,
rest,
aggregate.type,
aggregate.constraints,
opts
)
_ ->
false
end
end
end
def loaded?(record, [%Ash.Resource.Aggregate{} = aggregate | rest], opts) do
loaded_on_type?(
Map.get(record, aggregate.name),
rest,
aggregate.type,
aggregate.constraints,
opts
)
end
def loaded?(record, [%Ash.Resource.Calculation{} = resource_calculation | rest], opts) do
if opts[:strict?] && Enum.any?(resource_calculation.arguments) do
false
else
loaded_on_type?(
Map.get(record, resource_calculation.name),
rest,
resource_calculation.type,
resource_calculation.constraints,
opts
)
end
end
def loaded?(record, [%Ash.Resource.Attribute{} = attribute | rest], opts) do
selected?(record, attribute.name) &&
loaded_on_type?(
Map.get(record, attribute.name),
rest,
attribute.type,
attribute.constraints,
opts
)
end
def loaded?(%resource{} = record, [key | rest], opts) when is_atom(key) and not is_nil(key) do
loaded?(record, [Ash.Resource.Info.field(resource, key) | rest], opts)
end
def loaded?(record, [%rel{name: name} | rest], opts)
when rel in [
Ash.Resource.Relationships.HasOne,
Ash.Resource.Relationships.HasMany,
Ash.Resource.Relationships.BelongsTo,
Ash.Resource.Relationships.ManyToMany
] do
record
|> Map.get(name)
|> loaded?(rest, opts)
end
def loaded?(_, _, opts), do: Keyword.get(opts, :unknown, false)
defp loaded_on_type?(
%Ash.NotLoaded{},
_rest,
_type,
_constraints,
_opts
) do
false
end
defp loaded_on_type?(
_,
[],
_type,
_constraints,
_opts
) do
true
end
defp loaded_on_type?(empty, _, _, _, opts) when empty in [[], nil] do
Keyword.get(opts, :unknown, false)
end
defp loaded_on_type?(value, path, type, constraints, opts) do
Ash.Type.loaded?(type, value, path, constraints, opts)
end
@spec get_metadata(Ash.Resource.record(), atom | list(atom)) :: term
def get_metadata(record, key_or_path) do
get_in(record.__metadata__ || %{}, List.wrap(key_or_path))
end
@spec selected?(Ash.Resource.record(), atom) :: boolean
def selected?(record, field) do
case Map.get(record, field) do
%Ash.NotLoaded{} -> false
%Ash.ForbiddenField{} -> false
_ -> true
end
end
@doc false
def reserved_names do
[
:__struct__,
:__meta__,
:__metadata__,
:__order__,
:__lateral_join_source__,
:*,
:calculations,
:aggregates,
:relationships,
:as
]
end
@impl Spark.Dsl
def explain(dsl_state, _) do
Ash.Resource.Info.description(dsl_state)
end
end