defmodule Spark.Dsl.Transformer do
@moduledoc """
A transformer manipulates and/or validates the entire DSL state of a resource.
It's `transform/1` takes a `map`, which is just the values/configurations at each point
of the DSL. Don't manipulate it directly, if possible, instead use functions like
`get_entities/3` and `replace_entity/5` to manipulate it.
Use the `after?/1` and `before?/1` callbacks to ensure that your transformer
runs either before or after some other transformer.
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
real point in modifying the dsl that you return.
"""
@callback transform(map) ::
:ok
| {:ok, map}
| {:error, term}
| {:warn, map, String.t() | list(String.t())}
| :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 =
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
def get_persisted(dsl, key, default \\ nil) do
dsl
|> Map.get(:persist, %{})
|> Map.get(key, default)
end
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
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.OptionsHelpers.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
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
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
def get_entities(dsl_state, path) do
dsl_state
|> Map.get(path, %{entities: []})
|> Map.get(:entities, [])
end
def fetch_option(dsl_state, path, option) do
dsl_state
|> Map.get(path, %{opts: []})
|> Map.get(:opts)
|> Kernel.||([])
|> Keyword.fetch(option)
end
def get_option(dsl_state, path, option, default \\ nil) do
dsl_state
|> Map.get(path, %{opts: []})
|> Map.get(:opts)
|> Kernel.||([])
|> Keyword.get(option, default)
end
def set_option(dsl_state, path, option, value) do
dsl_state
|> Map.put_new(path, %{opts: []})
|> Map.update!(path, fn existing_opts ->
existing_opts
|> Map.put_new(:opts, [])
|> Map.update!(:opts, fn opts ->
Keyword.put(opts, option, value)
end)
end)
end
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.update(dsl_state, path, %{entities: [replacement], opts: []}, fn config ->
Map.update(config, :entities, [replacement], 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
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? ->
:digraph.add_edge(digraph, left, right)
left_after_right? ->
:digraph.add_edge(digraph, right, left)
true ->
:ok
end
end
end)
end)
transformers = walk_rest(digraph)
:digraph.delete(digraph)
transformers
end
defp walk_rest(digraph, 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 ->
raise "Cycle detected in transformer order"
vertex ->
:digraph.del_vertex(digraph, vertex)
walk_rest(digraph, acc ++ [vertex])
end
vertex ->
:digraph.del_vertex(digraph, vertex)
walk_rest(digraph, [vertex | acc])
end
end
end
end