defmodule Mecto do
@moduledoc """
"Mail merging" with Ecto structs.
A parser to interpolate MediaWiki-like `[[foo.bar]]` markup using data from Ecto schemas.
"""
@doc """
Extracts nodes from some markup, returning a map where the end values are the count
of how many times that node was in the markup.
Note: this doesn't validate the nodes, just extracts them or errors if the markup is
invalid.
## Examples
iex> Mecto.extract_nodes("some text [[blog_post.title]] and [[blog_post.comments[0].content]]")
%{blog_post: %{title: 1, comments: %{0 => %{content: 1}}}}
iex> Mecto.extract_nodes("some text that [[is|invalid]]")
{:error, "invalid markup"}
"""
defdelegate extract_nodes(text), to: Mecto.MarkupParser
@doc """
Builds a map from an Ecto schema, including following associations.
Note: this will not recurse if a struct is repeated deeper in the tree.
## Examples
iex> Mecto.parse_schema(Mecto.User)
%{id: :id, username: :string}
iex> Mecto.parse_schema(Mecto.Foo)
{:error, :missing_schema}
"""
defdelegate parse_schema(module), to: Mecto.SchemaExtractor, as: :convert_from
@doc """
Validates markup fields exist on the supplied schema.
## Examples
iex> Mecto.validate("Title for post #[[blog_post.id]]", Mecto.BlogPost)
%{blog_post: %{id: :id}}
iex> Mecto.validate("[[blog_post.invalid_field]]", Mecto.BlogPost)
{:error, ["blog_post.invalid_field does not exist"]}
"""
@spec validate(String.t(), module()) :: map() | {:error, [String.t()]}
def validate(text, module) do
schema = parse_schema(module)
keyed_schema = keyed_map(module, schema)
case extract_nodes(text) do
{:error, e} -> {:error, [e]}
nodes -> Mecto.SchemaValidator.check(keyed_schema, nodes)
end
end
@doc """
Interpolates markup with the values in the given Ecto structs.
Note: this does _not_ validate the markup (beyond checking it can be parsed) - it is
expected that any text passed to this has already been validated with `Mecto.validate/2`
## Examples
iex> Mecto.interpolate("Title for post [[blog_post.title]]", %Mecto.BlogPost{title: "some title"})
{:ok, "Title for post some title"}
iex> Mecto.interpolate("Some [[[invalid markup]]", %Mecto.BlogPost{})
{:error, "invalid markup"}
iex> Mecto.interpolate("Some [[blog_post.invalid_field]]", %Mecto.BlogPost{})
{:ok, "Some "}
"""
@spec interpolate(String.t(), map()) :: {:ok, String.t()} | {:error, String.t()}
def interpolate(text, data) do
%module{} = data
keyed_data = keyed_map(module, data)
case Mecto.MarkupParser.parse(text) do
{:error, _e, _, _, _, _} ->
{:error, "invalid markup"}
{:ok, nodes, _, _, _, _} ->
nodes
|> Enum.map(fn
node when is_binary(node) ->
node
{:field, path} ->
path =
Enum.map(path, fn
path_entry when is_integer(path_entry) ->
Access.at(path_entry)
path_entry ->
path_entry
|> String.to_existing_atom()
|> Access.key()
end)
get_in(keyed_data, path)
end)
|> Enum.join()
|> then(&{:ok, &1})
end
end
defp keyed_map(module, map) do
module_key =
Atom.to_string(module)
|> String.split(".")
|> List.last()
|> Macro.underscore()
|> String.to_atom()
%{module_key => map}
end
end