Packages

A set of macros to help facilitate struct-to-struct field mapping.

Current section

Files

Jump to
cartograf lib cartograf.ex
Raw

lib/cartograf.ex

defmodule Cartograf do
@moduledoc """
Cartograf is a set of elixir macros for mapping fields from
one struct to another.
The goal is to make these struct-to-struct translations more
robust and less cumbersome to write and maintian.
## Basic Form
The basic form for using this module is of the form:
```elixir
map Proj.A, Proj.B, :one_to_one do
let :a, :aa
let :b, :bb
let :c, :cc
let :d, :dd
end
```
This structure would create a function called `one_to_one/1`
within whatever module this the macro was invoked within.
The `one_to_one/1` function would expect a struct of type `Proj.A`
and return a struct of type `Proj.B` would be returned.
This Map generates a function that contains a native elixir struct syntax for
the destination struct. For instance, for the *Basic Example*, the following
function is generated.
```
def one_to_one(bnd = %Proj.A{}) do
%Proj.B{aa: bdn.a, bb: bdn.b, cc: bdn.c, dd: bdn.d}
end
```
# Design Philosophy
`cartograf` is supposed to be a tool, not a hazzard.
The point of this project is to create robust mappings from
one struct to another. As such, there are a few safeties in
place to protect the developer.
* `map()` does require that its input struct is of the
correct type. The function generated leverages pattern
matching on the argument to ensure that the struct
type is the one declared when the map was specified.
* All input fields *should* be handled. Each `map()`
will report any unmapped fields as a warning at compile
time. This can also be configured to not report a warning, fail
compilation, for more info see Config. In order to remove these
warnings, a `drop(input_key)` should be added to the `map()`
The main purpose for this is catch
instances where developers add fields to structs, but fail
to update the maps.
* Maps do not automatically map identical keys from one struct
to another by default. To enable this, the `auto: true` option must
be set in the `map`'s options.
# Configuration
Cartograf by default will warn about any unmapped fields to change this behaviour
the following configuration changes can be made.
* `config :cartograf, on_missing_key: :warn`
Log warning for each unmapped field
* `config :cartograf, on_missing_key: :ignore`
Ignore unmapped fields, don't warn or throw
* `config :cartograf, on_missing_key: :throw`
Raise an exception on unmapped fields, halting compilation
"""
defmacro __using__(_) do
quote do
import Cartograf
end
end
defp get_children({:__block__, _meta, elements}) do
elements
end
defp get_children(element) do
[element]
end
defp tokenize(children) do
Enum.reduce(children, %{}, fn {atm, tup}, acc ->
Map.update(acc, atm, [tup], fn val -> [tup | val] end)
end)
end
defp get_list(lst, atom) do
Map.get(lst, atom, [])
end
defp make_struct_map({bindings, to, bound_var}) do
bound = Macro.var(bound_var, __MODULE__)
const_mapping =
Enum.reduce(get_list(bindings, :const), [], fn {t, v}, acc ->
[{t, v} | acc]
end)
let_mapping =
Enum.reduce(get_list(bindings, :let), [], fn {f, t}, acc ->
[{t, quote(do: unquote(bound).unquote(f))} | acc]
end)
merged_mapping = let_mapping ++ const_mapping
{:%, [], [to, {:%{}, [], merged_mapping}]}
end
defp verify_mappings(mapped_keys, from_t) do
if(is_nil(from_t)) do
[]
else
from_keys = Map.keys(struct(from_t))
not_mapped =
Enum.filter(from_keys, fn key -> not (key in mapped_keys) and key != :__struct__ end)
not_mapped
end
end
defp unwrap_children(children, binding) do
Macro.postwalk(children, {[], []}, fn mappings, {mp, nmp} ->
mappings = Macro.expand(mappings, __ENV__)
case mappings do
{:nest, {key, nest_fn}} ->
{ast, {m, nm}} = nest_fn.(binding)
{{:const, {key, ast}}, {m ++ mp, nm ++ nmp}}
other ->
{other, {mp, nmp}}
end
end)
end
defp get_mapped(mappings, additional_mapped) do
Keyword.keys(get_list(mappings, :let)) ++
Enum.map(get_list(mappings, :const), &elem(&1, 0)) ++
Enum.map(get_list(mappings, :drop), &elem(&1, 0)) ++ additional_mapped
end
defp map_auto(true, mappings, mapped_nested, to_t, from_t) do
to_atoms = Map.keys(struct(to_t))
from_atoms = Map.keys(struct(from_t))
# Get keys that are already mapped
mapped = get_mapped(mappings, mapped_nested)
mapped = Enum.uniq(mapped)
# Get shared keys between to and from
shared = Enum.filter(to_atoms, fn key -> key in from_atoms end)
# Get shared keys that are not mapped
shared = Enum.filter(shared, fn key -> not (key in mapped) && key != :__struct__ end)
new_lets = Keyword.new(Enum.map(shared, fn a -> {a, a} end))
# Add let entries for missing shared keys
Map.update(mappings, :let, new_lets, fn val ->
Keyword.merge(val, new_lets)
end)
end
defp map_auto(false, mappings, _, _, _) do
mappings
end
defp map_internal(children, to_t, binding, auto?, from_t \\ nil) do
{children, {mapped_n, _not_mapped_n}} = unwrap_children(children, binding)
mappings = tokenize(children)
mappings = map_auto(auto?, mappings, mapped_n, to_t, from_t)
mapped = get_mapped(mappings, mapped_n)
mapped = Enum.uniq(mapped)
not_mapped = verify_mappings(mapped, from_t)
{make_struct_map({mappings, to_t, binding}), {mapped, not_mapped}}
end
defp report_not_mapped(not_mapped, name, env) do
if(Enum.any?(not_mapped)) do
msg =
"In map \"#{name}\" the following source keys are not mapped: \n#{inspect(not_mapped)}"
stack = Macro.Env.stacktrace(env)
case Application.get_env(:cartograf, :on_missing_key, :warn) do
:warn ->
IO.warn(msg, stack)
:throw ->
reraise(Cartograf.MappingException, [message: msg], stack)
:ignore ->
nil
u ->
IO.warn(
"Cartograf expected config field :on_missing_key to
be either :warn, :throw, or :ignore, got :#{u}",
stack
)
IO.warn(msg, stack)
end
end
end
defp map_p(from_t, to_t, name, auto?, map?, children, env) do
binding_raw = :carto
binding = Macro.var(binding_raw, __MODULE__)
{created_map, {_mapped, not_mapped}} =
map_internal(children, to_t, binding_raw, auto?, from_t)
report_not_mapped(not_mapped, name, env)
main =
quote do
def unquote(name)(unquote(binding) = %unquote(from_t){}) do
unquote(created_map)
end
end
if(map?) do
map =
quote do
def unquote(:"#{name}_map")(unquote(binding) = %unquote(from_t){}) do
Map.from_struct(unquote(created_map))
end
end
{main, map}
else
main
end
end
@doc """
Creates a function in the the current module for mapping from
struct to another.
```elixir
defmodule A, do: defstruct [:a, :b, :c]
defmodule B, do: defstruct [:aa, :bb, :cc]
defmodule YourModule do
use Cartograf
map A, B, :a_to_b do
let :a, :aa
let :b, :bb
let :c, :cc
end
end
```
```elixir
iex> YourModule.a_to_b(%A{a: 1, b: "2", c: :d})
%B{aa: 1, bb: "2", cc: :d}
```
The options:
* `auto: true` create bindings for all matching keys of the two
structs which are not already mapped
* `map: true` create a second anologous method `'name'_map` which will return
a map instead of the struct (some libraries rely of creating a struct for
you from a map of fields)
"""
@spec map(module(), module(), atom, [], do: any()) :: any()
defmacro map(from_t, to_t, name, opts \\ [], do: block) do
children = get_children(block)
from_t = Macro.expand(from_t, __CALLER__)
to_t = Macro.expand(to_t, __CALLER__)
auto? = Keyword.get(opts, :auto, false)
map? = Keyword.get(opts, :map, false)
map_p(from_t, to_t, name, auto?, map?, children, __CALLER__)
end
@doc """
Specify where the a field in the input should be mapped to
in the out.
"""
@spec let(atom(), atom()) :: any()
defmacro let(source_key, dest_key) do
{:let, {source_key, dest_key}}
end
@doc """
Allow for a field in the output to be set to a constant
value.
"""
@spec const(atom(), any()) :: any()
defmacro const(dest_key, val) do
{:const, {dest_key, val}}
end
@doc """
Used to specify a nested map within `map()`.
This resulting struct will have the type of to_t.
No options are available for this macro.
"""
@spec nest(atom(), module(), do: any()) :: any()
defmacro nest(dest_key, to_t, do: block) do
children = get_children(block)
to_t = Macro.expand(to_t, __CALLER__)
nest_scope = fn binding -> map_internal(children, to_t, binding, false) end
{:nest, {dest_key, nest_scope}}
end
@doc """
Allow for a field from the input to be excluded from
the output.
Most useful when using `auto`, however it
is recommended to use this for any non-mapped.
"""
@spec drop(atom()) :: any()
defmacro drop(src_key) do
{:drop, {src_key}}
end
end