chore: generalize DSL tooling

This commit is contained in:
Zach Daniel 2021-01-14 01:17:26 -05:00
parent 36749669ca
commit 13ca0b2976
5 changed files with 280 additions and 135 deletions

View file

@ -43,6 +43,9 @@ defmodule Ash do
_ -> [] _ -> []
end) end)
|> Enum.any?(&(&1 == behaviour)) |> Enum.any?(&(&1 == behaviour))
rescue
_ ->
false
end end
def uuid do def uuid do

View file

@ -365,41 +365,16 @@ defmodule Ash.Api do
alias Ash.Dsl.Extension alias Ash.Dsl.Extension
defmacro __using__(opts) do use Ash.Dsl, default_extensions: [extensions: [Ash.Api.Dsl]]
extensions = [Ash.Api.Dsl | opts[:extensions] || []]
body = def handle_opts(_) do
quote do quote do
@before_compile Ash.Api
@behaviour Ash.Api @behaviour Ash.Api
end end
preparations = Extension.prepare(extensions)
[body | preparations]
end
defmacro __before_compile__(_env) do
quote generated: true, unquote: false do
alias Ash.Dsl.Extension
@on_load :on_load
ash_dsl_config = Macro.escape(Extension.set_state())
@doc false
def ash_dsl_config do
unquote(ash_dsl_config)
end
def on_load do
Extension.load()
__MODULE__
|> Ash.Api.resources()
|> Enum.each(&Code.ensure_loaded/1)
end end
def handle_before_compile(_) do
quote do
use Ash.Api.Interface use Ash.Api.Interface
end end
end end

233
lib/ash/dsl/dsl.ex Normal file
View file

@ -0,0 +1,233 @@
defmodule Ash.Dsl do
@using_schema [
single_extension_kinds: [
type: {:list, :atom},
default: [],
doc:
"The extension kinds that are allowed to have a single value. For example: `[:data_layer]`"
],
many_extension_kinds: [
type: {:list, :atom},
default: [],
doc:
"The extension kinds that can have multiple values. e.g `[notifiers: [Notifier1, Notifier2]]`"
],
untyped_extensions?: [
type: :boolean,
default: true,
doc: "Whether or not to support an `extensions` key which contains untyped extensions"
],
default_extensions: [
type: :keyword_list,
default: [],
doc: """
The extensions that are included by default. e.g `[data_layer: Default, notifiers: [Notifier1]]`
Default values for single extension kinds are overwritten if specified by the implementor, while many extension
kinds are appended to if specified by the implementor.
"""
]
]
@moduledoc """
The primary entry point for adding a DSL to a module.
To add a DSL to a module, add `use Ash.Dsl, ...options`. The options supported with `use Ash.Dsl` are:
#{Ash.OptionsHelpers.docs(@using_schema)}
See the callbacks defined in this module to augment the behavior/compilation of the module getting a Dsl.
"""
@type opts :: Keyword.t()
@doc """
Validate/add options. Those options will be passed to `handle_opts` and `handle_before_compile`
"""
@callback init(opts) :: {:ok, opts} | {:error, String.t() | Ash.error()}
@doc """
Handle options in the context of the module. Must return a `quote` block.
If you want to persist anything in the DSL persistence layer,
use `@persist {:key, value}`. It can be called multiple times to
persist multiple times.
"""
@callback handle_opts(Keyword.t()) :: Macro.t()
@doc """
Handle options in the context of the module, after all extensions have been processed. Must return a `quote` block.
"""
@callback handle_before_compile(Keyword.t()) :: Macro.t()
defmacro __using__(opts) do
opts = Ash.OptionsHelpers.validate!(opts, @using_schema)
their_opt_schema =
Enum.map(opts[:single_extension_kinds], fn extension_kind ->
{extension_kind, type: :atom, default: opts[:default_extensions][extension_kind]}
end) ++
Enum.map(opts[:many_extension_kinds], fn extension_kind ->
{extension_kind, type: {:list, :atom}, default: []}
end)
their_opt_schema =
if opts[:untyped_extensions?] do
Keyword.put(their_opt_schema, :extensions, type: {:list, :atom})
else
their_opt_schema
end
quote bind_quoted: [
their_opt_schema: their_opt_schema,
parent_opts: opts,
parent: __CALLER__.module
] do
@dialyzer {:nowarn_function, handle_opts: 1, handle_before_compile: 1}
def init(opts), do: {:ok, opts}
def handle_opts(opts) do
quote do
end
end
def handle_before_compile(opts) do
quote do
end
end
defoverridable init: 1, handle_opts: 1, handle_before_compile: 1
defmacro __using__(opts) do
parent = unquote(parent)
parent_opts = unquote(parent_opts)
their_opt_schema = unquote(their_opt_schema)
{opts, extensions} =
parent_opts[:default_extensions]
|> Enum.reduce(opts, fn {key, defaults}, opts ->
Keyword.update(opts, key, defaults, fn current_value ->
cond do
key in parent_opts[:single_extension_kinds] ->
current_value || defaults
key in parent_opts[:many_extension_kinds] || key == :extensions ->
List.wrap(current_value) ++ List.wrap(defaults)
true ->
opts
end
end)
end)
|> Ash.Dsl.expand_modules(parent_opts, __CALLER__)
opts =
opts
|> Ash.OptionsHelpers.validate!(their_opt_schema)
|> init()
|> Ash.Dsl.unwrap()
body =
quote do
parent = unquote(parent)
opts = unquote(opts)
parent_opts = unquote(parent_opts)
their_opt_schema = unquote(their_opt_schema)
extensions = unquote(extensions)
@opts opts
@before_compile Ash.Dsl
@ash_is parent
@ash_parent parent
Module.register_attribute(__MODULE__, :persist, accumulate: true)
opts
|> @ash_parent.handle_opts()
|> Code.eval_quoted([], __ENV__)
for single_extension_kind <- parent_opts[:single_extension_kinds] do
@persist {single_extension_kind, opts[single_extension_kind]}
Module.put_attribute(__MODULE__, single_extension_kind, opts[single_extension_kind])
end
for many_extension_kind <- parent_opts[:many_extension_kinds] do
@persist {many_extension_kind, opts[many_extension_kind] || []}
Module.put_attribute(
__MODULE__,
many_extension_kind,
opts[many_extension_kind] || []
)
end
end
preparations = Ash.Dsl.Extension.prepare(extensions)
[body | preparations]
end
end
end
@doc false
def unwrap({:ok, value}), do: value
def unwrap({:error, error}), do: raise(error)
@doc false
def expand_modules(opts, their_opt_schema, env) do
Enum.reduce(opts, {[], []}, fn {key, value}, {opts, extensions} ->
cond do
key in their_opt_schema[:single_extension_kinds] ->
mod = Macro.expand(value, env)
extensions =
if Ash.implements_behaviour?(mod, Ash.Dsl.Extension) do
[mod | extensions]
else
extensions
end
{Keyword.put(opts, key, mod), extensions}
key in their_opt_schema[:many_extension_kinds] || key == :extensions ->
mods = value |> List.wrap() |> Enum.map(&Macro.expand(&1, env))
extensions =
extensions ++ Enum.filter(mods, &Ash.implements_behaviour?(&1, Ash.Dsl.Extension))
{Keyword.put(opts, key, mods), extensions}
true ->
{key, value}
end
end)
end
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defmacro __before_compile__(_env) do
quote unquote: false do
@type t :: __MODULE__
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))
def ash_dsl_config do
unquote(ash_dsl_config)
end
def on_load do
Ash.Dsl.Extension.load()
end
@opts
|> @ash_parent.handle_before_compile()
|> Code.eval_quoted([], __ENV__)
end
end
def is?(module, type) do
Ash.try_compile(module)
type in List.wrap(module.module_info(:attributes)[:ash_is])
rescue
_ ->
false
end
end

View file

@ -7,70 +7,44 @@ defmodule Ash.Resource do
alias Ash.Dsl.Extension alias Ash.Dsl.Extension
defmacro __using__(opts) do use Ash.Dsl,
data_layer = Macro.expand(opts[:data_layer], __CALLER__) single_extension_kinds: [:data_layer],
embedded? = data_layer == :embedded many_extension_kinds: [
:authorizers,
:notifiers
],
default_extensions: [
data_layer: Ash.DataLayer.Simple,
extensions: [Ash.Resource.Dsl]
]
data_layer = def init(opts) do
if embedded? do if opts[:data_layer] == :embedded do
Ash.DataLayer.Simple {:ok,
opts
|> Keyword.put(:data_layer, Ash.DataLayer.Simple)
|> Keyword.put(:embedded?, true)}
else else
data_layer || Ash.DataLayer.Simple {:ok, opts}
end
end end
opts = Keyword.put(opts, :data_layer, data_layer) def handle_opts(opts) do
quote bind_quoted: [embedded?: opts[:embedded?]] do
authorizers =
opts[:authorizers]
|> List.wrap()
|> Enum.map(&Macro.expand(&1, __CALLER__))
notifiers =
opts[:notifiers]
|> List.wrap()
|> Enum.map(&Macro.expand(&1, __CALLER__))
extensions =
if Ash.implements_behaviour?(data_layer, Ash.Dsl.Extension) do
[data_layer, Ash.Resource.Dsl]
else
[Ash.Resource.Dsl]
end
authorizer_extensions =
Enum.filter(authorizers, &Ash.implements_behaviour?(&1, Ash.Dsl.Extension))
notifier_extensions =
Enum.filter(notifiers, &Ash.implements_behaviour?(&1, Ash.Dsl.Extension))
extensions =
Enum.concat([
extensions,
opts[:extensions] || [],
authorizer_extensions,
notifier_extensions
])
body =
quote bind_quoted: [opts: opts, embedded?: embedded?] do
@before_compile Ash.Resource
@authorizers opts[:authorizers] || []
@notifiers opts[:notifiers] || []
@data_layer opts[:data_layer] || Ash.DataLayer.Simple
@extensions (opts[:extensions] || []) ++
List.wrap(opts[:data_layer] || Ash.DataLayer.Simple) ++
(opts[:authorizers] || [])
@embedded embedded?
if embedded? do if embedded? do
@persist {:embedded?, true}
Ash.Resource.define_embeddable_type() Ash.Resource.define_embeddable_type()
end end
end end
end
preparations = Extension.prepare(extensions) def handle_before_compile(_opts) do
quote do
require Ash.Schema
[body | preparations] Ash.Schema.define_schema()
end
end end
@embedded_resource_array_constraints [ @embedded_resource_array_constraints [
@ -560,44 +534,6 @@ defmodule Ash.Resource do
end end
end end
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defmacro __before_compile__(_env) do
quote unquote: false do
@doc false
alias Ash.Dsl.Extension
@type t :: %__MODULE__{}
Module.register_attribute(__MODULE__, :is_ash_resource, persist: true, accumulate: false)
@is_ash_resource true
@on_load :on_load
ash_dsl_config =
Macro.escape(
Extension.set_state(
notifiers: @notifiers,
authorizers: @authorizers,
data_layer: @data_layer,
embedded?: @embedded
)
)
@doc false
def ash_dsl_config do
unquote(ash_dsl_config)
end
def on_load do
Extension.load()
end
require Ash.Schema
Ash.Schema.define_schema()
end
end
@spec extensions(Ash.resource()) :: [module] @spec extensions(Ash.resource()) :: [module]
def extensions(resource) do def extensions(resource) do
Extension.get_persisted(resource, :extensions) Extension.get_persisted(resource, :extensions)
@ -653,12 +589,7 @@ defmodule Ash.Resource do
@doc "Whether or not a given module is a resource module" @doc "Whether or not a given module is a resource module"
@spec resource?(module) :: boolean @spec resource?(module) :: boolean
def resource?(module) when is_atom(module) do def resource?(module) when is_atom(module) do
Ash.try_compile(module) Ash.Dsl.is?(module, __MODULE__)
module.module_info(:attributes)[:is_ash_resource] == [true]
rescue
_ ->
false
end end
def resource?(_), do: false def resource?(_), do: false

View file

@ -114,6 +114,9 @@ defmodule Ash.MixProject do
Ash.Dsl.Section, Ash.Dsl.Section,
Ash.Dsl.Transformer Ash.Dsl.Transformer
], ],
"dsl tooling": [
Ash.Dsl
],
"resource dsl transformers": ~r/Ash.Resource.Transformers/, "resource dsl transformers": ~r/Ash.Resource.Transformers/,
"api dsl transformers": ~r/Ash.Api.Transformers/, "api dsl transformers": ~r/Ash.Api.Transformers/,
"filter operators": ~r/Ash.Query.Operator/, "filter operators": ~r/Ash.Query.Operator/,