Current section
Files
Jump to
Current section
Files
lib/transmog.ex
defmodule Transmog do
@moduledoc """
`Transmog` is a module which provides the ability to map keys on nested maps,
lists and structs to new values. It is useful for when you have external data
that you want to convert into an internal format easily. This recursive
transformation is made using an internal concept known as key pairs.
## Key Pairs
Key pairs are a list of two-tuples which represent a mapping from a key in the
source map to a key in the destination map. The key mapping does not have to
be exhaustive and any values that are skipped are simply added to the result.
## Examples
#=> "credentials", "first_name" to :identity, :first_name
[{["credentials", "first_name"], [:identity, :first_name]}]
When key pairs are simple they can be represented using an internal DSL which
is a dot notation string. These dot notation strings also support atoms if you
prefix them with `:`.
## Examples
#=> Same as the previous example
[{"credentials.first_name", ":identity.:first_name"}]
## Parsing
All of the supported key pair formats implement the `Transmog.Parser`
protocol. Technically if you wanted to add support for a different type then
you could implement the protocol for them.
## Formatting
We validate key pairs when they are provided to the main entrypoint of the
library, `format/2` and `format!/2`. If the key pairs are not valid then we
will let you know with an error.
## Examples
#=> Notice: key paths must be equal length!
iex> key_paths = [{"credentials", ":identity.:first_name"}]
iex> Transmog.format(%{"credentials" => "Mike Rudolph"}, key_paths)
{:error, :invalid_key_pairs}
iex> key_paths = [{"", ":last_name"}]
iex> Transmog.format(%{}, key_paths)
{:error, :invalid_key_path}
Once your key pairs are validated you can start to use them to transform your
nested maps and lists.
## Examples
#=> Notice: you need to be explicit about which keys you want updated
iex> key_paths = [
...> {"credentials", ":identity"},
...> {"credentials.first_name", ":identity.:first_name"}
...> ]
iex> source = %{"credentials" => %{"first_name" => "Tom"}}
iex> {:ok, result} = Transmog.format(source, key_paths)
iex> result
%{identity: %{first_name: "Tom"}}
iex> key_paths = [
...> {"credentials", ":identity"},
...> {"credentials.first_name", ":identity.:first_name"}
...> ]
iex> source = [
...> %{"credentials" => %{"first_name" => "John"}},
...> %{"credentials" => %{"first_name" => "Sally"}}
...> ]
iex> {:ok, result} = Transmog.format(source, key_paths)
iex> result
[%{identity: %{first_name: "John"}}, %{identity: %{first_name: "Sally"}}]
If you know that your key pairs are valid then you can use `format!/2` instead
and your results will be unwrapped automatically for you.
## Examples
iex> key_paths = [{"name", ":name"}]
iex> source = %{"name" => "Jimmy"}
iex> Transmog.format!(source, key_paths)
%{name: "Jimmy"}
You can even transform your structs. When a struct is encountered during the
parse it will be converted into a map automatically for you.
"""
alias Transmog.KeyPairs
@typedoc """
`t:key_paths/0` is the type for a single tuple of key paths. Both sides can
be any type as long as they can be parsed by `Transmog.Parser`.
"""
@type key_paths :: {term, term}
@doc """
`format/2` takes a source value and either a list of key paths or a
`Transmog.KeyPair` struct directly and performs the key transformation on the
value.
If the key paths are given then they will be parsed using
`Transmog.KeyPairs.parse/1` and will report errors if any occur during that.
## Examples
iex> key_paths = [{"a", ":a"}, {"a.b", ":a.:b"}]
iex> source = %{"a" => %{"b" => "c"}}
iex> {:ok, result} = Transmog.format(source, key_paths)
iex> result
%{a: %{b: "c"}}
iex> key_paths = [{"a", ":a"}, {"a.b", ":a.:b"}]
iex> {:ok, %Transmog.KeyPairs{} = key_pairs} = Transmog.KeyPairs.parse(key_paths)
iex> source = %{"a" => %{"b" => "c"}}
iex> {:ok, result} = Transmog.format(source, key_pairs)
iex> result
%{a: %{b: "c"}}
"""
@spec format(source :: term, mapping :: KeyPairs.t() | list(key_paths)) ::
{:ok, term} | KeyPairs.error()
def format(source, %KeyPairs{} = key_pairs), do: {:ok, do_format(source, key_pairs)}
def format(source, key_paths) do
with {:ok, %KeyPairs{} = key_pairs} <- KeyPairs.parse(key_paths) do
format(source, key_pairs)
end
end
@doc """
`format!/2` takes a source value and either a list of key paths or a
`Transmog.KeyPair` struct directly and performs the key transformation on the
value.
This function will raise an error if the `Transmog.KeyPair` struct cannot be
created by parsing the key paths. The result will be automatically unwrapped
if the operation is successful.
## Examples
iex> key_paths = [{"a", ":a"}, {"a.b", ":a.:b"}]
iex> source = %{"a" => %{"b" => "c"}}
iex> Transmog.format!(source, key_paths)
%{a: %{b: "c"}}
iex> key_paths = [{"a", ":a"}, {"a.b", ":a.:b"}]
iex> %Transmog.KeyPairs{} = key_pairs = Transmog.KeyPairs.parse!(key_paths)
iex> source = %{"a" => %{"b" => "c"}}
iex> Transmog.format!(source, key_pairs)
%{a: %{b: "c"}}
"""
@spec format!(source :: term, mapping :: KeyPairs.t() | list(key_paths)) :: term
def format!(source, %KeyPairs{} = key_pairs), do: do_format(source, key_pairs)
def format!(source, key_paths) do
%KeyPairs{} = key_pairs = KeyPairs.parse!(key_paths)
format!(source, key_pairs)
end
# Formats a single level of a map or list. If the input is a list then the
# formatter is run over each map in the list. If the input is a map then each
# key is formatted and each value has `do_format/3` called on it.
@spec do_format(source :: term, key_pairs :: KeyPairs.t(), prefix :: list(term)) :: term
defp do_format(source, key_pairs, prefix \\ [])
defp do_format(source, %KeyPairs{} = key_pairs, prefix) when is_list(source) do
Enum.map(source, &do_format(&1, key_pairs, prefix))
end
defp do_format(%_{} = source, %KeyPairs{} = key_pairs, prefix) when is_list(prefix) do
source
|> Map.from_struct()
|> do_format(key_pairs, prefix)
end
defp do_format(%{} = source, %KeyPairs{} = key_pairs, prefix) when is_list(prefix) do
for {key, value} <- source, into: %{} do
current_path = prefix ++ [key]
{match_path(key_pairs, current_path), do_format(value, key_pairs, current_path)}
end
end
defp do_format(source, _, _), do: source
# Given the key pairs and path, attempts to find a match in the key pairs
# list. If no match is found then the key that was passed will be returned.
# Because we know the list isn't empty and that we fallback to the passed
# key, we know that `hd/1` should never raise.
@spec match_path(key_pairs :: KeyPairs.t(), path :: nonempty_list(term)) :: term
defp match_path(%KeyPairs{} = key_pairs, path) when is_list(path) do
key_pairs
|> KeyPairs.find_match(path)
|> Enum.reverse()
|> hd()
end
end