Packages
spark
2.7.2
2.7.2
2.7.1
2.7.0
2.6.1
2.6.0
2.5.0
2.4.1
2.4.0
2.3.14
2.3.13
2.3.12
2.3.11
2.3.10
2.3.9
2.3.8
2.3.7
2.3.6
2.3.5
2.3.4
2.3.3
2.3.2
2.3.1
2.3.0
2.2.69
2.2.68
2.2.67
2.2.66
2.2.65
2.2.64
2.2.63
2.2.62
2.2.61
2.2.60
2.2.59
2.2.58
2.2.57
2.2.56
2.2.55
2.2.54
2.2.53
2.2.52
2.2.51
2.2.50
2.2.49
2.2.48
2.2.47
2.2.46
2.2.45
2.2.44
2.2.43
2.2.42
2.2.41
2.2.40
2.2.39
2.2.38
2.2.37
2.2.36
2.2.35
2.2.34
2.2.33
2.2.32
2.2.31
2.2.30
2.2.29
2.2.28
2.2.27
2.2.26
2.2.25
2.2.24
2.2.23
2.2.22
2.2.21
2.2.20
2.2.19
2.2.18
2.2.17
2.2.16
2.2.15
2.2.14
2.2.13
2.2.12
2.2.11
2.2.10
2.2.9
2.2.8
2.2.7
2.2.6
2.2.5
2.2.4
2.2.3
2.2.2
2.2.1
2.2.0
2.1.24
2.1.23
2.1.22
2.1.21
2.1.20
2.1.19
2.1.18
2.1.17
2.1.16
2.1.15
2.1.14
2.1.13
2.1.12
2.1.11
2.1.10
2.1.9
2.1.8
2.1.7
2.1.6
2.1.5
2.1.4
2.1.3
2.1.2
2.1.1
2.1.0
2.0.1
2.0.0
1.1.55
1.1.54
1.1.53
1.1.52
1.1.51
1.1.50
1.1.49
1.1.48
1.1.47
1.1.46
1.1.45
1.1.44
1.1.43
1.1.42
1.1.41
1.1.40
1.1.39
1.1.38
1.1.37
1.1.36
1.1.35
1.1.34
1.1.32
1.1.31
1.1.30
1.1.29
1.1.28
1.1.27
1.1.26
1.1.25
1.1.24
1.1.22
1.1.21
1.1.20
1.1.19
1.1.18
1.1.17
1.1.16
1.1.15
1.1.14
retired
1.1.13
1.1.12
1.1.11
1.1.10
1.1.9
1.1.8
1.1.7
1.1.6
1.1.5
1.1.4
1.1.3
1.1.2
1.1.1
1.1.0
1.0.9
1.0.8
1.0.7
1.0.6
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.16
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.29
0.1.28
0.1.27
0.1.26
0.1.25
0.1.24
0.1.23
0.1.22
0.1.21
0.1.20
0.1.19
0.1.18
0.1.17
0.1.16
0.1.15
0.1.14
0.1.13
0.1.12
0.1.11
0.1.10
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
Generic tooling for building DSLs
Current section
Files
Jump to
Current section
Files
lib/spark/dsl/transformer.ex
# SPDX-FileCopyrightText: 2022 spark contributors <https://github.com/ash-project/spark/graphs/contributors>
#
# SPDX-License-Identifier: MIT
defmodule Spark.Dsl.Transformer do
@moduledoc """
A transformer manipulates and/or validates the entire DSL state of a module at compile time.
Transformers run during compilation and can read, modify, and persist DSL state. They are the
primary mechanism for setting defaults, building entities programmatically, caching computed
values, and reshaping configuration before the module is finalized.
## Usage
defmodule MyApp.MyExtension.Transformers.SetDefaults do
use Spark.Dsl.Transformer
def transform(dsl_state) do
# Read entities and options
actions = Spark.Dsl.Transformer.get_entities(dsl_state, [:actions])
# Modify DSL state and return it
dsl_state = Spark.Dsl.Transformer.persist(dsl_state, :action_count, length(actions))
{:ok, dsl_state}
end
end
## Callbacks
- `transform/1` - Receives the DSL state map. Return `{:ok, dsl_state}` to continue with
modifications, `:ok` to continue without modifications, `{:error, term}` to halt with an
error, `{:warn, dsl_state, warnings}` to continue with warnings, or `:halt` to silently
stop processing further transformers.
- `before?/1` - Return `true` if this transformer must run before the given transformer module.
- `after?/1` - Return `true` if this transformer must run after the given transformer module.
## Reading vs. Modifying State
Use the helper functions in this module rather than manipulating the DSL state map directly.
For reading: `get_entities/2`, `get_option/3`, `fetch_option/3`, `get_persisted/2`.
For writing: `add_entity/3`, `replace_entity/3`, `remove_entity/3`, `set_option/4`, `persist/3`.
## Transformers vs. Persisters vs. Verifiers
**Persisters** also use this behaviour (`use Spark.Dsl.Transformer`) but are declared under
`persisters:` in the extension rather than `transformers:`. They always run after all
transformers have completed, regardless of any `before?`/`after?` declarations targeting
transformer modules. By convention, persisters should only write to the persisted data map
via `persist/3` and should not mutate sections or entities. They support `before?`/`after?`
ordering relative to other persisters. See `Spark.Dsl.Extension` for more.
**Verifiers** (`Spark.Dsl.Verifier`) are for validation only — they run after compilation,
cannot modify DSL state, and do not create compile-time dependencies between modules.
"""
@type warning() :: String.t() | {String.t(), :erl_anno.anno()}
@callback transform(map) ::
:ok
| {:ok, map}
| {:error, term}
| {:warn, map, warning() | list(warning())}
| :halt
@callback before?(module) :: boolean
@callback after?(module) :: boolean
@callback after_compile?() :: boolean
defmacro __using__(_) do
quote generated: true do
@behaviour Spark.Dsl.Transformer
def before?(_), do: false
def after?(_), do: false
def after_compile?, do: false
defoverridable before?: 1, after?: 1, after_compile?: 0
end
end
@doc """
Saves a value into the dsl config with the given key.
This can be used to precompute some information and cache it onto the resource,
or simply store a computed value. It can later be retrieved with `Spark.Dsl.Extension.get_persisted/3`.
"""
def persist(dsl, key, value) do
Map.update(dsl, :persist, %{key => value}, &Map.put(&1, key, value))
end
@doc """
Runs the function in an async compiler.
Use this for compiling new modules and having them compiled
efficiently asynchronously.
"""
def async_compile(dsl, fun) do
task = Spark.Dsl.Extension.do_async_compile(fun)
tasks = get_persisted(dsl, :spark_compile_tasks, [])
persist(dsl, :spark_compile_tasks, [task | tasks])
end
@doc """
Add a quoted expression to be evaluated in the DSL module's context.
Use this *extremely sparingly*. It should almost never be necessary, unless building certain
extensions that *require* the module in question to define a given function.
What you likely want is either one of the DSL introspection functions, like `Spark.Dsl.Extension.get_entities/2`
or `Spark.Dsl.Extension.get_opt/5)`. If you simply want to store a custom value that can be retrieved easily, or
cache some precomputed information onto the resource, use `persist/3`.
Provide the dsl state, bindings that should be unquote-able, and the quoted block
to evaluate in the module. For example, if we wanted to support a `resource.primary_key()` function
that would return the primary key (this is unnecessary, just an example), we might do this:
```elixir
fields = the_primary_key_fields
dsl_state =
Spark.Dsl.Transformer.eval(
dsl_state,
[fields: fields],
quote do
def primary_key() do
unquote(fields)
end
end
)
```
"""
def eval(dsl, bindings, block) do
to_eval = {block, bindings}
Map.update(
dsl,
:eval,
[to_eval],
&[to_eval | &1]
)
end
@doc """
Retrieves a value that was previously stored with `persist/3`.
Returns `default` if the key has not been persisted.
"""
def get_persisted(dsl, key, default \\ nil) do
dsl
|> Map.get(:persist, %{})
|> Map.get(key, default)
end
@doc """
Fetches a persisted value, returning `{:ok, value}` or `:error`.
"""
def fetch_persisted(dsl, key) do
dsl
|> Map.get(:persist, %{})
|> Map.fetch(key)
end
@doc """
Same as `build_entity/4` but raises on error.
"""
def build_entity!(extension, path, name, opts) do
case build_entity(extension, path, name, opts) do
{:ok, entity} ->
entity
{:error, error} ->
if is_exception(error) do
raise error
else
raise "Error building entity #{inspect(error)}"
end
end
end
@doc """
Programmatically builds an entity struct defined in the given extension.
`path` is the section path (e.g. `[:actions]`), `name` is the entity name (e.g. `:read`),
and `opts` is a keyword list of options matching the entity's schema. The entity's schema
validations and transforms are applied.
Commonly used to add entities to DSL state in a transformer:
{:ok, action} = Transformer.build_entity(Ash.Resource.Dsl, [:actions], :read, name: :read)
dsl_state = Transformer.add_entity(dsl_state, [:actions], action)
"""
def build_entity(extension, path, name, opts) do
do_build_entity(extension.sections(), path, name, opts)
end
defp do_build_entity(sections, [section_name], name, opts) do
section = Enum.find(sections, &(&1.name == section_name))
entity = Enum.find(section.entities, &(&1.name == name))
do_build(entity, opts)
end
defp do_build_entity(
sections,
[section_name, maybe_entity_name],
maybe_nested_entity_name,
opts
) do
section = Enum.find(sections, &(&1.name == section_name))
entity =
if section do
Enum.find(section.entities, &(&1.name == maybe_entity_name))
end
sub_entity =
if entity do
entity.entities
|> Keyword.values()
|> List.flatten()
|> Enum.find(&(&1.name == maybe_nested_entity_name))
end
if sub_entity do
do_build(sub_entity, opts)
else
do_build_entity(section.sections, [maybe_entity_name], maybe_nested_entity_name, opts)
end
end
defp do_build_entity(sections, [section_name | rest], name, opts) do
section = Enum.find(sections, &(&1.name == section_name))
do_build_entity(section.sections, rest, name, opts)
end
defp do_build(entity, opts) do
entity_names =
entity.entities
|> Kernel.||([])
|> Keyword.keys()
{entities, opts} = Keyword.split(opts, entity_names)
{before_validate_auto, after_validate_auto} =
Keyword.split(entity.auto_set_fields || [], Keyword.keys(entity.schema))
with {:ok, opts} <-
Spark.Options.validate(
Keyword.merge(opts, before_validate_auto),
entity.schema
),
opts <- Keyword.merge(opts, after_validate_auto) do
result = struct(struct(entity.target, opts), entities)
case Spark.Dsl.Entity.transform(entity.transform, result) do
{:ok, built} ->
Spark.Dsl.Entity.maybe_apply_identifier(built, entity.identifier)
other ->
other
end
else
{:error, error} ->
{:error, error}
end
end
@doc """
Adds an entity to the DSL state at the given section path.
By default, entities are prepended. Pass `type: :append` to append instead.
"""
def add_entity(dsl_state, path, entity, opts \\ []) do
Map.update(dsl_state, path, %{entities: [entity], opts: []}, fn config ->
Map.update(config, :entities, [entity], fn entities ->
if (opts[:type] || :prepend) == :prepend do
[entity | entities]
else
entities ++ [entity]
end
end)
end)
end
@doc """
Removes entities at the given path that match the filter function.
The function receives each entity and should return `true` for entities to remove.
"""
def remove_entity(dsl_state, path, func) do
Map.update(dsl_state, path, %{entities: [], opts: []}, fn config ->
Map.update(config, :entities, [], fn entities ->
Enum.reject(entities, func)
end)
end)
end
@doc """
Returns all entities at the given section path.
"""
def get_entities(dsl_state, path) do
dsl_state
|> Map.get(path, %{entities: []})
|> Map.get(:entities, [])
end
@doc """
Fetches a DSL option, returning `{:ok, value}` or `:error`.
"""
def fetch_option(dsl_state, path, option) do
dsl_state
|> Map.get(path, Spark.Dsl.Extension.default_section_config())
|> Map.get(:opts)
|> Kernel.||([])
|> Keyword.fetch(option)
end
@doc """
Returns the source location annotation for a section, useful for error reporting.
"""
@spec get_section_anno(map, list(atom)) :: :erl_anno.anno() | nil
def get_section_anno(dsl_state, path) do
dsl_state
|> Map.get(path, %{})
|> Map.get(:section_anno)
end
@doc """
Returns the source location annotation for a specific option, useful for error reporting.
"""
@spec get_opt_anno(map, list(atom), atom) :: :erl_anno.anno() | nil
def get_opt_anno(dsl_state, path, option) do
dsl_state
|> Map.get(path, Spark.Dsl.Extension.default_section_config())
|> Map.get(:opts_anno, [])
|> Keyword.get(option)
end
@doc """
Gets a DSL option value at the given section path, returning `default` if not set.
"""
def get_option(dsl_state, path, option, default \\ nil) do
dsl_state
|> Map.get(path, Spark.Dsl.Extension.default_section_config())
|> Map.get(:opts)
|> Kernel.||([])
|> Keyword.get(option, default)
end
@doc """
Sets a DSL option value at the given section path.
"""
def set_option(dsl_state, path, option, value) do
dsl_state
|> Map.put_new(path, Spark.Dsl.Extension.default_section_config())
|> Map.update!(path, fn existing_opts ->
existing_opts
|> Map.put_new(:opts, [])
|> Map.put_new(:opts_anno, [])
|> Map.update!(:opts, fn opts ->
Keyword.put(opts, option, value)
end)
end)
end
@doc """
Replaces an entity at the given path with `replacement`.
By default, matches on struct type and `__identifier__`. Pass a custom `matcher`
function to control which entity gets replaced.
"""
def replace_entity(dsl_state, path, replacement, matcher \\ nil) do
matcher =
matcher ||
fn record ->
record.__struct__ == replacement.__struct__ and
record.__identifier__ == replacement.__identifier__
end
Map.replace_lazy(dsl_state, path, fn config ->
Map.replace_lazy(config, :entities, fn entities ->
replace_match(entities, replacement, matcher)
end)
end)
end
defp replace_match(entities, replacement, matcher) do
Enum.map(entities, fn entity ->
if matcher.(entity) do
replacement
else
entity
end
end)
end
@doc false
def sort(transformers) do
digraph = :digraph.new()
transformers
|> Enum.each(fn transformer ->
:digraph.add_vertex(digraph, transformer)
end)
transformers
|> Enum.each(fn left ->
transformers
|> Enum.each(fn right ->
if left != right do
left_before_right? = left.before?(right) || right.after?(left)
left_after_right? = left.after?(right) || right.before?(left)
cond do
# This is annoying, but some modules have `def after?(_), do: true`
# The idea being that they'd like to go after everything that isn't
# explicitly after it. Same with `def before?(_), do: true`
left_before_right? && left_after_right? ->
:ok
left_before_right? ->
if right not in :digraph.out_neighbours(digraph, left) do
:digraph.add_edge(digraph, left, right)
end
left_after_right? ->
if left not in :digraph.out_neighbours(digraph, right) do
:digraph.add_edge(digraph, right, left)
end
true ->
:ok
end
end
end)
end)
transformers = walk_rest(digraph, transformers)
:digraph.delete(digraph)
transformers
end
defp walk_rest(digraph, transformers, acc \\ []) do
case :digraph.vertices(digraph) do
[] ->
Enum.reverse(acc)
vertices ->
case Enum.find(vertices, &(:digraph.in_neighbours(digraph, &1) == [])) do
nil ->
case Enum.find(vertices, &(:digraph.out_neighbours(digraph, &1) == [])) do
nil ->
vertex =
Enum.min_by(vertices, fn l ->
Enum.find_index(transformers, &(&1 == l))
end)
:digraph.del_vertex(digraph, vertex)
walk_rest(digraph, transformers, acc ++ [vertex])
vertex ->
:digraph.del_vertex(digraph, vertex)
walk_rest(digraph, transformers, acc ++ [vertex])
end
vertex ->
:digraph.del_vertex(digraph, vertex)
walk_rest(digraph, transformers, [vertex | acc])
end
end
end
end