improvement: breaking change! don't define code interface by default

In an effort to improve compile times in general, and remove unnecessary
compile time dependencies, the code interface is not defined by default.
It is also now possible to define the code interface directly in the resource module,
via

```elixir
code_interface do
  define_for ApiModule
end
```

If you need to reenable the code interface, simply add the following to your api module:
```elixir
resources do
  define_interfaces? true
  ...
end
```
This commit is contained in:
Zach Daniel 2021-10-06 17:43:22 -04:00
parent a955d89b8f
commit 87627993b8
23 changed files with 602 additions and 511 deletions

View file

@ -9,7 +9,6 @@ locals_without_parens = [
args: 1,
argument: 2,
argument: 3,
as: 1,
attribute: 1,
attribute: 2,
attribute: 3,
@ -34,6 +33,8 @@ locals_without_parens = [
define: 1,
define: 2,
define_field?: 1,
define_for: 1,
define_interfaces?: 1,
description: 1,
destination_field: 1,
destination_field_on_join_table: 1,
@ -122,7 +123,6 @@ locals_without_parens = [
validate: 2,
validate_destination_field?: 1,
violation_message: 1,
warn_on_compile_failure?: 1,
where: 1,
writable?: 1
]

View file

@ -493,6 +493,11 @@ defmodule Ash.Api do
|> Enum.map(& &1.resource)
end
@spec define_interfaces?(atom) :: boolean
def define_interfaces?(api) do
Extension.get_opt(api, [:resources], :define_interfaces?, false)
end
@spec resource_references(Ash.Api.t()) :: [Ash.Api.ResourceReference.t()]
def resource_references(api) do
Extension.get_entities(api, [:resources])

View file

@ -3,27 +3,12 @@ defmodule Ash.Api.Dsl do
name: :resource,
describe: "A reference to a resource",
target: Ash.Api.ResourceReference,
modules: [:resource],
args: [:resource],
examples: [
"resource MyApp.User"
],
# This is an internal tool used by embedded resources,
# so we hide it from the documentation
hide: [:warn_on_compile_failure?],
schema: [
warn_on_compile_failure?: [
type: :atom,
default: true
],
as: [
type: :atom,
required: false,
doc: """
A short name for the resource.
Can be used in calls to Api modules, e.g `Api.read(:special_thing)`.
"""
],
resource: [
type: :atom,
required: true,
@ -44,13 +29,23 @@ defmodule Ash.Api.Dsl do
end
"""
],
schema: [
define_interfaces?: [
type: :boolean,
default: false,
doc: """
If set to true, the code interface of each resource will be defined in the api.
Keep in mind that this can increase the compile times of your application.
"""
]
],
entities: [
@resource
]
}
@transformers [
Ash.Api.Transformers.EnsureResourcesCompiled,
Ash.Api.Transformers.ValidateRelatedResourceInclusion,
Ash.Api.Transformers.ValidateRelationshipAttributes,
Ash.Api.Transformers.ValidateManyToManyJoinAttributes
@ -63,6 +58,12 @@ defmodule Ash.Api.Dsl do
Apis are the entrypoints for working with your resources.
Apis may optionally include a list of resources, in which case they can be
used as an `Ash.Registry` in various places. This is for backwards compatibility,
but if at all possible you should define an `Ash.Registry` if you are using an extension
that requires a list of resources. For example, most extensions look for two application
environment variables called `:ash_apis` and `:ash_registries` to find any potential registries
# Table of Contents
#{Ash.Dsl.Extension.doc_index(@sections)}

View file

@ -1,366 +1,16 @@
defmodule Ash.Api.Interface do
@moduledoc false
@doc false
def require_action(resource, interface) do
action = Ash.Resource.Info.action(resource, interface.action || interface.name)
unless action do
raise Ash.Error.Dsl.DslError,
module: resource,
message:
"The interface of #{inspect(resource)} refers to a non-existent action #{interface.action || interface.name}",
path: [:interfaces, :interface, interface.name]
end
action
end
defmacro define_interface(api, resource) do
quote bind_quoted: [api: api, resource: resource], generated: true, location: :keep do
for interface <- Ash.Resource.Info.interfaces(resource) do
action = Ash.Api.Interface.require_action(resource, interface)
args = interface.args || []
arg_vars = Enum.map(args, &{&1, [], Elixir})
doc = """
#{action.description || "Calls the #{action.name} action on the #{inspect(resource)} resource."}
## Options
#{Ash.OptionsHelpers.docs(Ash.Resource.Interface.interface_options(action.type))}
"""
case action.type do
:read ->
@doc doc
@dialyzer {:nowarn_function, {interface.name, Enum.count(args) + 2}}
def unquote(interface.name)(
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(
__MODULE__,
elem(__ENV__.function, 0),
[
unquote_splicing(arg_vars),
%{},
params_or_opts
]
)
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
query =
opts[:query]
|> Kernel.||(unquote(resource))
|> Ash.Query.for_read(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
if unquote(interface.get?) do
query
|> unquote(api).read_one(Keyword.drop(opts, [:query, :tenant]))
|> case do
{:ok, nil} ->
{:error, Ash.Error.Query.NotFound.exception(resource: query.resource)}
{:ok, result} ->
{:ok, result}
{:error, error} ->
{:error, error}
end
else
unquote(api).read(query, Keyword.drop(opts, [:query, :tenant]))
end
end
end
@doc doc
# sobelow_skip ["DOS.BinToAtom"]
@dialyzer {:nowarn_function, {:"#{interface.name}!", Enum.count(args) + 2}}
def unquote(:"#{interface.name}!")(
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(
__MODULE__,
elem(__ENV__.function, 0),
[
unquote_splicing(arg_vars),
%{},
params_or_opts
]
)
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
query =
opts[:query]
|> Kernel.||(unquote(resource))
|> Ash.Query.for_read(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
if unquote(interface.get?) do
query
|> unquote(api).read_one!(Keyword.drop(opts, [:query, :tenant]))
|> case do
nil ->
raise Ash.Error.Query.NotFound, resource: query.resource
result ->
result
end
else
unquote(api).read!(query, Keyword.drop(opts, [:query, :tenant]))
end
end
end
:create ->
@doc doc
@dialyzer {:nowarn_function, {interface.name, Enum.count(args) + 2}}
def unquote(interface.name)(
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
unquote_splicing(arg_vars),
%{},
params_or_opts
])
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
changeset =
opts[:changeset]
|> Kernel.||(unquote(resource))
|> Ash.Changeset.for_create(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
unquote(api).create(changeset, Keyword.drop(opts, [:actor, :changeset, :tenant]))
end
end
@doc doc
@dialyzer {:nowarn_function, {:"#{interface.name}!", Enum.count(args) + 2}}
# sobelow_skip ["DOS.BinToAtom"]
def unquote(:"#{interface.name}!")(
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
unquote_splicing(arg_vars),
%{},
params_or_opts
])
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
changeset =
(opts[:changeset] || unquote(resource))
|> Ash.Changeset.for_create(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
unquote(api).create!(changeset, Keyword.drop(opts, [:actor, :changeset]))
end
end
:update ->
@doc doc
@dialyzer {:nowarn_function, {interface.name, Enum.count(args) + 3}}
def unquote(interface.name)(
record,
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
record,
unquote_splicing(arg_vars),
%{},
params_or_opts
])
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
changeset =
record
|> Ash.Changeset.for_update(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
unquote(api).update(changeset, Keyword.drop(opts, [:actor, :tenant]))
end
end
@doc doc
# sobelow_skip ["DOS.BinToAtom"]
@dialyzer {:nowarn_function, {:"#{interface.name}!", Enum.count(args) + 3}}
def unquote(:"#{interface.name}!")(
record,
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
record,
unquote_splicing(arg_vars),
%{},
params_or_opts
])
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
changeset =
record
|> Ash.Changeset.for_update(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
unquote(api).update!(changeset, Keyword.drop(opts, [:actor, :tenant]))
end
end
:destroy ->
@doc doc
@dialyzer {:nowarn_function, {interface.name, Enum.count(args) + 3}}
def unquote(interface.name)(
record,
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
record,
unquote_splicing(arg_vars),
%{},
params_or_opts
])
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
changeset =
record
|> Ash.Changeset.for_destroy(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
unquote(api).destroy(changeset, Keyword.drop(opts, [:actor, :tenant]))
end
end
@doc doc
# sobelow_skip ["DOS.BinToAtom"]
@dialyzer {:nowarn_function, {:"#{interface.name}!", Enum.count(args) + 3}}
def unquote(:"#{interface.name}!")(
record,
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
record,
unquote_splicing(arg_vars),
%{},
params_or_opts
])
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
changeset =
record
|> Ash.Changeset.for_destroy(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
unquote(api).destroy!(changeset, Keyword.drop(opts, [:actor, :tenant]))
end
end
end
end
end
end
defmacro __using__(_) do
quote bind_quoted: [], generated: true do
alias Ash.Api
for resource <- Ash.Api.resources(__MODULE__) do
Ash.Api.Interface.define_interface(__MODULE__, resource)
if Ash.Api.define_interfaces?(__MODULE__) do
require Ash.CodeInterface
for resource <- Ash.Api.resources(__MODULE__) do
Ash.CodeInterface.define_interface(__MODULE__, resource)
end
end
def get!(resource, id_or_filter, params \\ []) do

View file

@ -1,7 +1,7 @@
defmodule Ash.Api.ResourceReference do
@moduledoc "Represents a resource in an API"
defstruct [:resource, :as, warn_on_compile_failure?: true]
defstruct [:resource, :as]
@type t :: %__MODULE__{}
end

View file

@ -9,47 +9,16 @@ defmodule Ash.Api.Transformers.EnsureResourcesCompiled do
alias Ash.Dsl.Transformer
require Logger
def transform(module, dsl, times \\ 3) do
@impl true
def after_compile?, do: true
@impl true
def transform(_api, dsl) do
dsl
|> Transformer.get_entities([:resources])
|> Enum.filter(& &1.warn_on_compile_failure?)
|> Enum.map(& &1.resource)
|> Enum.map(fn resource ->
try do
# This is to get the compiler to ensure that the resource is compiled
# For some very strange reason, `Code.ensure_compiled/1` isn't enough
resource.ash_dsl_config()
rescue
_ ->
:ok
end
case Code.ensure_compiled(resource) do
{:module, _module} ->
false
{:error, error} ->
# The module is being compiled but is in a deadlock that may or may not be resolved
{resource, error}
end
|> Enum.each(fn resource ->
resource.ash_dsl_config()
end)
|> Enum.filter(& &1)
|> case do
[] ->
{:ok, dsl}
rejected ->
if times == 0 do
for {resource, error} <- rejected do
Logger.error(
"Could not ensure that #{inspect(resource)} was compiled: #{inspect(error)}"
)
end
:halt
else
transform(module, dsl, times - 1)
end
end
end
end

View file

@ -6,6 +6,14 @@ defmodule Ash.Api.Transformers.ValidateManyToManyJoinAttributes do
alias Ash.Dsl.Transformer
@impl true
def after_compile?, do: true
@impl true
def after?(Ash.Api.Transformers.EnsureResourcesCompiled), do: true
def after?(_), do: false
@impl true
def transform(_api, dsl) do
dsl
|> Transformer.get_entities([:resources])
@ -36,7 +44,4 @@ defmodule Ash.Api.Transformers.ValidateManyToManyJoinAttributes do
end
end
end
def after?(Ash.Api.Transformers.EnsureResourcesCompiled), do: true
def after?(_), do: false
end

View file

@ -6,6 +6,9 @@ defmodule Ash.Api.Transformers.ValidateRelatedResourceInclusion do
alias Ash.Dsl.Transformer
@impl true
def after_compile?, do: true
@impl true
def after?(Ash.Api.Transformers.EnsureResourcesCompiled), do: true
def after?(_), do: false
@ -15,7 +18,6 @@ defmodule Ash.Api.Transformers.ValidateRelatedResourceInclusion do
resources =
dsl
|> Transformer.get_entities([:resources])
|> Enum.filter(& &1.warn_on_compile_failure?)
|> Enum.map(& &1.resource)
resources

View file

@ -6,10 +6,17 @@ defmodule Ash.Api.Transformers.ValidateRelationshipAttributes do
alias Ash.Dsl.Transformer
@impl true
def after_compile?, do: true
@impl true
def after?(Ash.Api.Transformers.EnsureResourcesCompiled), do: true
def after?(_), do: false
@impl true
def transform(_api, dsl) do
dsl
|> Transformer.get_entities([:resources])
|> Enum.filter(& &1.warn_on_compile_failure?)
|> Enum.map(& &1.resource)
|> Enum.each(fn resource ->
attribute_names =
@ -71,7 +78,4 @@ defmodule Ash.Api.Transformers.ValidateRelationshipAttributes do
"Relationship `#{relationship.name}` expects destination field `#{relationship.destination_field}` to be defined on #{inspect(relationship.destination)}"
end
end
def after?(Ash.Api.Transformers.EnsureResourcesCompiled), do: true
def after?(_), do: false
end

359
lib/ash/code_interface.ex Normal file
View file

@ -0,0 +1,359 @@
defmodule Ash.CodeInterface do
@moduledoc """
Used to define the functions of a code interface for a resource.
"""
@doc false
def require_action(resource, interface) do
action = Ash.Resource.Info.action(resource, interface.action || interface.name)
unless action do
raise Ash.Error.Dsl.DslError,
module: resource,
message:
"The interface of #{inspect(resource)} refers to a non-existent action #{interface.action || interface.name}",
path: [:interfaces, :interface, interface.name]
end
action
end
defmacro define_interface(api, resource) do
quote bind_quoted: [api: api, resource: resource], generated: true, location: :keep do
for interface <- Ash.Resource.Info.interfaces(resource) do
action = Ash.CodeInterface.require_action(resource, interface)
args = interface.args || []
arg_vars = Enum.map(args, &{&1, [], Elixir})
doc = """
#{action.description || "Calls the #{action.name} action on the #{inspect(resource)} resource."}
## Options
#{Ash.OptionsHelpers.docs(Ash.Resource.Interface.interface_options(action.type))}
"""
case action.type do
:read ->
@doc doc
@dialyzer {:nowarn_function, {interface.name, Enum.count(args) + 2}}
def unquote(interface.name)(
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(
__MODULE__,
elem(__ENV__.function, 0),
[
unquote_splicing(arg_vars),
%{},
params_or_opts
]
)
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
query =
opts[:query]
|> Kernel.||(unquote(resource))
|> Ash.Query.for_read(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
if unquote(interface.get?) do
query
|> unquote(api).read_one(Keyword.drop(opts, [:query, :tenant]))
|> case do
{:ok, nil} ->
{:error, Ash.Error.Query.NotFound.exception(resource: query.resource)}
{:ok, result} ->
{:ok, result}
{:error, error} ->
{:error, error}
end
else
unquote(api).read(query, Keyword.drop(opts, [:query, :tenant]))
end
end
end
@doc doc
# sobelow_skip ["DOS.BinToAtom"]
@dialyzer {:nowarn_function, {:"#{interface.name}!", Enum.count(args) + 2}}
def unquote(:"#{interface.name}!")(
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(
__MODULE__,
elem(__ENV__.function, 0),
[
unquote_splicing(arg_vars),
%{},
params_or_opts
]
)
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
query =
opts[:query]
|> Kernel.||(unquote(resource))
|> Ash.Query.for_read(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
if unquote(interface.get?) do
query
|> unquote(api).read_one!(Keyword.drop(opts, [:query, :tenant]))
|> case do
nil ->
raise Ash.Error.Query.NotFound, resource: query.resource
result ->
result
end
else
unquote(api).read!(query, Keyword.drop(opts, [:query, :tenant]))
end
end
end
:create ->
@doc doc
@dialyzer {:nowarn_function, {interface.name, Enum.count(args) + 2}}
def unquote(interface.name)(
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
unquote_splicing(arg_vars),
%{},
params_or_opts
])
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
changeset =
opts[:changeset]
|> Kernel.||(unquote(resource))
|> Ash.Changeset.for_create(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
unquote(api).create(changeset, Keyword.drop(opts, [:actor, :changeset, :tenant]))
end
end
@doc doc
@dialyzer {:nowarn_function, {:"#{interface.name}!", Enum.count(args) + 2}}
# sobelow_skip ["DOS.BinToAtom"]
def unquote(:"#{interface.name}!")(
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
unquote_splicing(arg_vars),
%{},
params_or_opts
])
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
changeset =
(opts[:changeset] || unquote(resource))
|> Ash.Changeset.for_create(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
unquote(api).create!(changeset, Keyword.drop(opts, [:actor, :changeset]))
end
end
:update ->
@doc doc
@dialyzer {:nowarn_function, {interface.name, Enum.count(args) + 3}}
def unquote(interface.name)(
record,
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
record,
unquote_splicing(arg_vars),
%{},
params_or_opts
])
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
changeset =
record
|> Ash.Changeset.for_update(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
unquote(api).update(changeset, Keyword.drop(opts, [:actor, :tenant]))
end
end
@doc doc
# sobelow_skip ["DOS.BinToAtom"]
@dialyzer {:nowarn_function, {:"#{interface.name}!", Enum.count(args) + 3}}
def unquote(:"#{interface.name}!")(
record,
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
record,
unquote_splicing(arg_vars),
%{},
params_or_opts
])
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
changeset =
record
|> Ash.Changeset.for_update(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
unquote(api).update!(changeset, Keyword.drop(opts, [:actor, :tenant]))
end
end
:destroy ->
@doc doc
@dialyzer {:nowarn_function, {interface.name, Enum.count(args) + 3}}
def unquote(interface.name)(
record,
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
record,
unquote_splicing(arg_vars),
%{},
params_or_opts
])
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
changeset =
record
|> Ash.Changeset.for_destroy(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
unquote(api).destroy(changeset, Keyword.drop(opts, [:actor, :tenant]))
end
end
@doc doc
# sobelow_skip ["DOS.BinToAtom"]
@dialyzer {:nowarn_function, {:"#{interface.name}!", Enum.count(args) + 3}}
def unquote(:"#{interface.name}!")(
record,
unquote_splicing(arg_vars),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
record,
unquote_splicing(arg_vars),
%{},
params_or_opts
])
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
changeset =
record
|> Ash.Changeset.for_destroy(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant])
)
unquote(api).destroy!(changeset, Keyword.drop(opts, [:actor, :tenant]))
end
end
end
end
end
end
end

View file

@ -149,9 +149,16 @@ defmodule Ash.Dsl do
@opts opts
@before_compile Ash.Dsl
@after_compile __MODULE__
@ash_is parent
@ash_parent parent
defmacro __after_compile__(_, _) do
quote do
Ash.Dsl.Extension.run_after_compile()
end
end
Module.register_attribute(__MODULE__, :persist, accumulate: true)
opts
@ -222,16 +229,11 @@ defmodule Ash.Dsl do
Module.register_attribute(__MODULE__, :ash_is, persist: true)
Module.put_attribute(__MODULE__, :ash_is, @ash_is)
@on_load :on_load
ash_dsl_config = Macro.escape(Ash.Dsl.Extension.set_state(@persist))
Ash.Dsl.Extension.set_state(@persist)
def ash_dsl_config do
unquote(ash_dsl_config)
end
def on_load do
Ash.Dsl.Extension.load()
@ash_dsl_config
end
@opts

View file

@ -105,16 +105,35 @@ defmodule Ash.Dsl.Extension do
@callback sections() :: [Ash.Dsl.section()]
@callback transformers() :: [module]
defp dsl!(resource) do
resource.ash_dsl_config()
rescue
UndefinedFunctionError ->
try do
Module.get_attribute(resource, :ash_dsl_config) || %{}
rescue
ArgumentError ->
try do
resource.ash_dsl_config()
rescue
_ ->
reraise ArgumentError,
"""
No such entity #{inspect(resource)} found.
""",
__STACKTRACE__
end
end
end
@doc "Get the entities configured for a given section"
def get_entities(resource, path) do
Ash.Helpers.try_compile(resource)
:persistent_term.get({resource, :ash, path}, %{entities: []}).entities
dsl!(resource)[path][:entities] || []
end
@doc "Get a value that was persisted while transforming or compiling the resource, e.g `:primary_key`"
def get_persisted(resource, key, default \\ nil) do
Ash.Helpers.try_compile(resource)
:persistent_term.get({resource, key}, default)
Map.get(dsl!(resource)[:persist] || %{}, key, default)
end
@doc """
@ -131,22 +150,10 @@ defmodule Ash.Dsl.Extension do
value
_ ->
Ash.Helpers.try_compile(resource)
Keyword.get(
:persistent_term.get({resource, :ash, path}, %{opts: []}).opts,
value,
default
)
Keyword.get(dsl!(resource)[path][:opts] || [], value, default)
end
else
Ash.Helpers.try_compile(resource)
Keyword.get(
:persistent_term.get({resource, :ash, path}, %{opts: []}).opts,
value,
default
)
Keyword.get(dsl!(resource)[path][:opts] || [], value, default)
end
end
@ -404,19 +411,6 @@ defmodule Ash.Dsl.Extension do
body =
quote location: :keep do
@extensions unquote(extensions)
# Due to a few strange stateful bugs I've seen,
# we clear the process of any potentially related state
for {key, _value} <- Process.get() do
if is_tuple(key) and elem(key, 0) == __MODULE__ do
Process.delete(key)
end
end
for {key, _value} <- :persistent_term.get() do
if is_tuple(key) and elem(key, 0) == __MODULE__ do
:persistent_term.erase(key)
end
end
end
imports =
@ -433,7 +427,7 @@ defmodule Ash.Dsl.Extension do
end
@doc false
defmacro set_state(additional_persisted_data \\ []) do
defmacro set_state(additional_persisted_data) do
quote generated: true,
location: :keep,
bind_quoted: [additional_persisted_data: additional_persisted_data] do
@ -461,7 +455,7 @@ defmodule Ash.Dsl.Extension do
&Map.merge(&1, persist)
)
Ash.Dsl.Extension.write_dsl_to_persistent_term(__MODULE__, ash_dsl_config)
@ash_dsl_config ash_dsl_config
for {key, _value} <- Process.get() do
if is_tuple(key) and elem(key, 0) == __MODULE__ do
@ -473,27 +467,35 @@ defmodule Ash.Dsl.Extension do
@extensions
|> Enum.flat_map(& &1.transformers())
|> Transformer.sort()
|> Enum.reject(& &1.after_compile?())
__MODULE__
|> Ash.Dsl.Extension.run_transformers(
transformers_to_run,
ash_dsl_config
ash_dsl_config,
true
)
end
end
defmacro load do
defmacro run_after_compile do
quote do
Ash.Dsl.Extension.write_dsl_to_persistent_term(
__MODULE__,
ash_dsl_config()
)
transformers_to_run =
@extensions
|> Enum.flat_map(& &1.transformers())
|> Ash.Dsl.Transformer.sort()
|> Enum.filter(& &1.after_compile?())
:ok
__MODULE__
|> Ash.Dsl.Extension.run_transformers(
transformers_to_run,
Module.get_attribute(__MODULE__, :ash_dsl_config),
false
)
end
end
def run_transformers(mod, transformers, ash_dsl_config) do
def run_transformers(mod, transformers, ash_dsl_config, store?) do
Enum.reduce_while(transformers, ash_dsl_config, fn transformer, dsl ->
result =
try do
@ -509,11 +511,17 @@ defmodule Ash.Dsl.Extension do
end
case result do
:ok ->
{:cont, dsl}
:halt ->
{:halt, dsl}
{:ok, new_dsl} ->
write_dsl_to_persistent_term(mod, new_dsl)
if store? do
Module.put_attribute(mod, :ash_dsl_config, new_dsl)
end
{:cont, new_dsl}
{:error, error} ->
@ -522,21 +530,6 @@ defmodule Ash.Dsl.Extension do
end)
end
@doc false
def write_dsl_to_persistent_term(mod, dsl) do
dsl
|> Map.delete(:persist)
|> Enum.each(fn {section_path, value} ->
:persistent_term.put({mod, :ash, section_path}, value)
end)
Enum.each(Map.get(dsl, :persist, %{}), fn {key, value} ->
:persistent_term.put({mod, key}, value)
end)
dsl
end
defp raise_transformer_error(transformer, error) do
if Exception.exception?(error) do
raise error

View file

@ -9,15 +9,15 @@ defmodule Ash.Dsl.Transformer do
Use the `after?/1` and `before?/1` callbacks to ensure that your transformer
runs either before or after some other transformer.
The pattern for requesting information from other modules that use the DSL and are
also currently compiling has not yet been determined. If you have that requirement
you will need extra utilities to ensure that some other DSL based module has either
completed or reached a certain point in its transformers. These utilities have not
yet been written.
Return `true` in `after_compile/0` to have the transformer run in an `after_compile` hook,
but keep in mind that no modifications to the dsl structure will be retained, so there is no
point in returning a new dsl structure from `transform/2` if `after_compile/0` is defined. Instead,
simply return `:ok` or `{:error, error}`
"""
@callback transform(module, map) :: {:ok, map} | {:error, term} | :halt
@callback transform(module, map) :: :ok | {:ok, map} | {:error, term} | :halt
@callback before?(module) :: boolean
@callback after?(module) :: boolean
@callback after_compile?() :: boolean
defmacro __using__(_) do
quote do
@ -25,8 +25,9 @@ defmodule Ash.Dsl.Transformer do
def before?(_), do: false
def after?(_), do: false
def after_compile?, do: false
defoverridable before?: 1, after?: 1
defoverridable before?: 1, after?: 1, after_compile?: 0
end
end

View file

@ -564,7 +564,7 @@ defmodule Ash.EmbeddableType do
@parent parent
resources do
resource @parent, warn_on_compile_failure?: false
resource @parent, []
end
end

View file

@ -1843,6 +1843,7 @@ defmodule Ash.Filter do
defp validate_not_crossing_datalayer_boundaries(refs, resource, expr) do
refs
|> Enum.map(&Ash.Resource.Info.related(resource, &1.relationship_path))
|> Enum.filter(& &1)
|> Enum.group_by(&Ash.DataLayer.data_layer/1)
|> Map.to_list()
|> case do

View file

@ -3,7 +3,16 @@ defmodule Ash.Helpers do
@spec try_compile(term) :: :ok
def try_compile(module) when is_atom(module) do
Code.ensure_loaded(module)
try do
# This is to get the compiler to ensure that the resource is compiled
# For some very strange reason, `Code.ensure_compiled/1` isn't enough
module.ash_dsl_config()
rescue
_ ->
:ok
end
Code.ensure_compiled!(module)
:ok
end

View file

@ -56,8 +56,11 @@ defmodule Ash.OptionsHelpers do
{:list, values} ->
{:list, sanitize_type(values)}
{:ash_behaviour, behaviour, builtins} ->
{:custom, __MODULE__, :ash_behaviour, [behaviour, builtins]}
{:ash_behaviour, behaviour, _builtins} ->
{:custom, __MODULE__, :ash_behaviour, [behaviour]}
{:ash_behaviour, behaviour} ->
{:custom, __MODULE__, :ash_behaviour, [behaviour]}
:ash_resource ->
:atom
@ -72,7 +75,7 @@ defmodule Ash.OptionsHelpers do
end
end
def ash_behaviour({module, opts}, _behaviour, _builtins) when is_atom(module) do
def ash_behaviour({module, opts}, _behaviour) when is_atom(module) do
if Keyword.keyword?(opts) do
# We can't check if it implements the behaviour here, unfortunately
# As it may not be immediately available
@ -82,11 +85,11 @@ defmodule Ash.OptionsHelpers do
end
end
def ash_behaviour(module, behaviour, builtins) when is_atom(module) do
ash_behaviour({module, []}, behaviour, builtins)
def ash_behaviour(module, behaviour) when is_atom(module) do
ash_behaviour({module, []}, behaviour)
end
def ash_behaviour(other, _, _) do
def ash_behaviour(other, _) do
{:error, "Expected a module and opts, got: #{inspect(other)}"}
end

56
lib/ash/registry/dsl.ex Normal file
View file

@ -0,0 +1,56 @@
# defmodule Ash.Registry.Dsl do
# @entry %Ash.Dsl.Entity{
# name: :entry,
# describe: "A reference to a a module",
# target: Ash.Registry.EntryReference,
# args: [:entry],
# examples: [
# "entry MyApp.Post"
# ],
# schema: [
# entry: [
# type: :atom,
# required: true,
# doc: "The module of the entry"
# ]
# ]
# }
# @entries %Ash.Dsl.Section{
# name: :entries,
# describe: "List the entries present in this registry",
# examples: [
# """
# entries do
# entry MyApp.User
# entry MyApp.Post
# entry MyApp.Comment
# end
# """
# ],
# entities: [
# @entry
# ]
# }
# @sections [@resources]
# @moduledoc """
# A small DSL for declaring APIs
# Apis are the entrypoints for working with your resources.
# Apis may optionally include a list of resources, in which case they can be
# used as an `Ash.Registry` in various places. This is for backwards compatibility,
# but if at all possible you should define an `Ash.Registry` if you are using an extension
# that requires a list of resources. For example, most extensions look for two application
# environment variables called `:ash_apis` and `:ash_registries` to find any potential registries
# # Table of Contents
# #{Ash.Dsl.Extension.doc_index(@sections)}
# #{Ash.Dsl.Extension.doc(@sections)}
# """
# use Ash.Dsl.Extension, sections: @sections, transformers: @transformers
# end

View file

@ -315,6 +315,7 @@ defmodule Ash.Resource.Dsl do
target: Ash.Resource.Change,
transform: {Ash.Resource.Change, :transform, []},
schema: Ash.Resource.Change.action_schema(),
modules: [:change],
args: [:change]
}
@ -378,6 +379,7 @@ defmodule Ash.Resource.Dsl do
target: Ash.Resource.Change,
transform: {Ash.Resource.Change, :transform, []},
schema: Ash.Resource.Change.schema(),
modules: [:change],
args: [:change]
}
@ -735,11 +737,20 @@ defmodule Ash.Resource.Dsl do
examples: [
"""
code_interface do
auto_define MyApp.Api
define :create_user, action: :create
define :get_user_by_id, action: :get_by_id, args: [:id], get?: true
end
"""
],
schema: [
define_for: [
type: {:ash_behaviour, Ash.Api},
doc:
"Defines the code interface on the resource module directly, using the provided Api.",
default: false
]
],
entities: [
@define
]

View file

@ -88,6 +88,16 @@ defmodule Ash.Resource.Info do
Extension.get_entities(resource, [:code_interface])
end
@spec define_interface_in_resource?(Ash.Resource.t()) :: boolean
def define_interface_in_resource?(resource) do
!!Extension.get_opt(resource, [:code_interface], :define_for, false)
end
@spec define_interface_for(Ash.Resource.t()) :: atom | nil
def define_interface_for(resource) do
Extension.get_opt(resource, [:code_interface], :define_for, nil)
end
@spec extensions(Ash.Resource.t()) :: [module]
def extensions(resource) do
Extension.get_persisted(resource, :extensions, [])

View file

@ -6,6 +6,19 @@ defmodule Ash.Resource.Interface do
@type t :: %__MODULE__{}
defmacro __using__(_) do
quote bind_quoted: [], generated: true do
if Ash.Resource.Info.define_interface_in_resource?(__MODULE__) do
require Ash.CodeInterface
Ash.CodeInterface.define_interface(
Ash.Resource.Info.define_interface_for(__MODULE__),
__MODULE__
)
end
end
end
def interface_options(action_type) do
[
tenant: [

View file

@ -243,16 +243,11 @@ defmodule Ash.Test.Changeset.ChangesetTest do
|> Changeset.new()
end
test "it returns an error for a non-resource record" do
assert %Changeset{
action_type: :update,
attributes: %{},
data: %NonResource{},
errors: [%Ash.Error.Invalid.NoSuchResource{}],
valid?: false
} =
%NonResource{name: "foo"}
|> Changeset.new()
test "it raises an error for a non-resource record" do
assert_raise ArgumentError, ~r/No such entity/, fn ->
%NonResource{name: "foo"}
|> Changeset.new()
end
end
end

View file

@ -39,6 +39,8 @@ defmodule Ash.Test.CodeInterfaceTest do
use Ash.Api
resources do
define_interfaces?(true)
resource(User)
end
end