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"} """ @spec extract_nodes(String.t()) :: map() | {:error, String.t()} 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} """ @spec parse_schema(module()) :: map() | {:error, atom()} 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 @spec keyed_map(module(), map()) :: map() 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