defmodule Miss.Map do
@moduledoc """
Functions to extend the Elixir `Map` module.
"""
@type keys_to_rename :: [{actual_key :: Map.key(), new_key :: Map.key()}] | map()
@type transform :: [{module(), (module() -> term()) | :skip}]
@typep map_to_list :: [{Map.key(), Map.value()}]
@doc """
Converts a `struct` to map going through all nested structs, different from `Map.from_struct/1`
that only converts the root struct.
The optional parameter `transform` receives a list of tuples with the struct module and a
function to be called instead of converting to a map. The transforming function will receive the
struct as a single parameter.
If you want to skip the conversion of a nested struct, just pass the atom `:skip` instead of a
transformation function.
`Date` or `Decimal` values are common examples where their map representation could be not so
useful when converted to a map. See the examples for more details.
## Examples
# Given the following structs
defmodule Post do
defstruct [:title, :text, :date, :author, comments: []]
end
defmodule Author do
defstruct [:name, :metadata]
end
defmodule Comment do
defstruct [:text]
end
defmodule Metadata do
defstruct [:atom, :boolean, :decimal, :float, :integer, :map]
end
# Convert all nested structs including the Date and Decimal values:
iex> post = %Post{
...> title: "My post",
...> text: "Something really interesting",
...> date: ~D[2010-09-01],
...> author: %Author{
...> name: "Pedro Bonamides",
...> metadata: %Metadata{
...> atom: :my_atom,
...> boolean: true,
...> decimal: Decimal.new("456.78"),
...> float: 987.54,
...> integer: 2_345_678,
...> map: %{key: "value"}
...> }
...> },
...> comments: [
...> %Comment{text: "Comment one"},
...> %Comment{text: "Comment two"}
...> ]
...> }
...> #{inspect(__MODULE__)}.from_nested_struct(post)
%{
title: "My post",
text: "Something really interesting",
date: %{calendar: Calendar.ISO, day: 1, month: 9, year: 2010},
author: %{
name: "Pedro Bonamides",
metadata: %{
atom: :my_atom,
boolean: true,
decimal: %{coef: 45678, exp: -2, sign: 1},
float: 987.54,
integer: 2_345_678,
map: %{key: "value"}
}
},
comments: [
%{text: "Comment one"},
%{text: "Comment two"}
]
}
# Convert all nested structs skipping the Date values and transforming Decimal values to string:
iex> post = %Post{
...> title: "My post",
...> text: "Something really interesting",
...> date: ~D[2010-09-01],
...> author: %Author{
...> name: "Pedro Bonamides",
...> metadata: %Metadata{
...> atom: :my_atom,
...> boolean: true,
...> decimal: Decimal.new("456.78"),
...> float: 987.54,
...> integer: 2_345_678,
...> map: %{key: "value"}
...> }
...> },
...> comments: [
...> %Comment{text: "Comment one"},
...> %Comment{text: "Comment two"}
...> ]
...> }
...> #{inspect(__MODULE__)}.from_nested_struct(post, [{Date, :skip}, {Decimal, &to_string/1}])
%{
title: "My post",
text: "Something really interesting",
date: ~D[2010-09-01],
author: %{
name: "Pedro Bonamides",
metadata: %{
atom: :my_atom,
boolean: true,
decimal: "456.78",
float: 987.54,
integer: 2_345_678,
map: %{key: "value"}
}
},
comments: [
%{text: "Comment one"},
%{text: "Comment two"}
]
}
"""
@spec from_nested_struct(struct(), transform()) :: map()
def from_nested_struct(struct, transform \\ []) when is_struct(struct),
do: to_map(struct, transform)
@spec to_map(term(), transform()) :: term()
defp to_map(%module{} = struct, transform) do
transform
|> Keyword.get(module)
|> case do
nil ->
struct
|> Map.from_struct()
|> to_nested_map(transform)
fun when is_function(fun, 1) ->
fun.(struct)
:skip ->
struct
end
end
defp to_map(value, transform) when is_map(value),
do: to_nested_map(value, transform)
defp to_map(list, transform) when is_list(list),
do: Enum.map(list, fn item -> to_map(item, transform) end)
defp to_map(value, _transform), do: value
@spec to_nested_map(map(), transform()) :: map()
defp to_nested_map(map, transform) do
map
|> Map.keys()
|> Enum.reduce(%{}, fn key, new_map ->
value =
map
|> Map.get(key)
|> to_map(transform)
Map.put(new_map, key, value)
end)
end
@doc """
Gets the value for a specific `key` in `map`.
If `key` is present in `map`, the corresponding value is returned. Otherwise, a `KeyError` is
raised.
`Miss.Map.get!/2` is similar to `Map.fetch!/2` but more efficient. Using pattern matching is the
fastest way to access maps. `Miss.Map.get!/2` uses pattern matching, but `Map.fetch!/2` not.
## Examples
iex> Miss.Map.get!(%{a: 1, b: 2}, :a)
1
iex> Miss.Map.get!(%{a: 1, b: 2}, :c)
** (KeyError) key :c not found in: %{a: 1, b: 2}
"""
@spec get!(map(), Map.key()) :: Map.value()
def get!(map, key) do
case map do
%{^key => value} -> value
%{} -> :erlang.error({:badkey, key, map})
non_map -> :erlang.error({:badmap, non_map})
end
end
@doc """
Renames a single key in the given `map`.
If `actual_key` does not exist in `map`, it is simply ignored.
If a key is renamed to an existing key, the value of the actual key remains.
## Examples
iex> Miss.Map.rename_key(%{a: 1, b: 2, c: 3}, :b, :bbb)
%{a: 1, bbb: 2, c: 3}
iex> Miss.Map.rename_key(%{"a" => 1, "b" => 2, "c" => 3}, "b", "bbb")
%{"a" => 1, "bbb" => 2, "c" => 3}
iex> Miss.Map.rename_key(%{a: 1, b: 2, c: 3}, :z, :zzz)
%{a: 1, b: 2, c: 3}
iex> Miss.Map.rename_key(%{a: 1, b: 2, c: 3}, :a, :c)
%{b: 2, c: 1}
iex> Miss.Map.rename_key(%{a: 1, b: 2, c: 3}, :c, :a)
%{a: 3, b: 2}
"""
@spec rename_key(map(), Map.key(), Map.key()) :: map()
def rename_key(map, actual_key, new_key) when is_map(map) do
case :maps.take(actual_key, map) do
{value, new_map} -> :maps.put(new_key, value, new_map)
:error -> map
end
end
def rename_key(non_map, _actual_key, _new_key), do: :erlang.error({:badmap, non_map})
@doc """
Renames keys in the given `map`.
Keys to be renamed are given through `keys_to_rename` that accepts either:
* a list of two-element tuples: `{actual_key, new_key}`; or
* a map where the keys are the actual keys and the values are the new keys: `%{actual_key => new_key}`
If `keys_to_rename` contains keys that are not in `map`, they are simply ignored.
It is not recommended to use `#{inspect(__MODULE__)}.rename_keys/2` to rename keys to existing
keys. But if you do it, after renaming the keys, duplicate keys are removed and the value of the
preceding one prevails. See the examples for more details.
## Examples
iex> Miss.Map.rename_keys(%{a: 1, b: 2, c: 3}, %{a: :aaa, c: :ccc})
%{aaa: 1, b: 2, ccc: 3}
iex> Miss.Map.rename_keys(%{a: 1, b: 2, c: 3}, a: :aaa, c: :ccc)
%{aaa: 1, b: 2, ccc: 3}
iex> Miss.Map.rename_keys(%{"a" => 1, "b" => 2, "c" => 3}, %{"a" => "aaa", "b" => "bbb"})
%{"aaa" => 1, "bbb" => 2, "c" => 3}
iex> Miss.Map.rename_keys(%{"a" => 1, "b" => 2, "c" => 3}, [{"a", "aaa"}, {"b", "bbb"}])
%{"aaa" => 1, "bbb" => 2, "c" => 3}
iex> Miss.Map.rename_keys(%{a: 1, b: 2, c: 3}, a: :aaa, z: :zzz)
%{aaa: 1, b: 2, c: 3}
iex> Miss.Map.rename_keys(%{a: 1, b: 2, c: 3}, a: :c)
%{b: 2, c: 1}
iex> Miss.Map.rename_keys(%{a: 1, b: 2, c: 3}, c: :a)
%{a: 1, b: 2}
iex> Miss.Map.rename_keys(%{a: 1, b: 2, c: 3}, [])
%{a: 1, b: 2, c: 3}
"""
@spec rename_keys(map(), keys_to_rename()) :: map()
def rename_keys(map, []) when is_map(map), do: map
def rename_keys(map, keys_to_rename) when is_map(map) and keys_to_rename == %{}, do: map
def rename_keys(map, keys_to_rename) when is_map(map) and is_list(keys_to_rename),
do: rename_keys(map, :maps.from_list(keys_to_rename))
def rename_keys(map, keys_to_rename) when is_map(map) and is_map(keys_to_rename) do
map
|> :maps.to_list()
|> do_rename_keys(keys_to_rename, _acc = [])
end
def rename_keys(non_map, _keys_to_rename), do: :erlang.error({:badmap, non_map})
@spec do_rename_keys(map_to_list(), map(), map_to_list()) :: map()
defp do_rename_keys([], _keys_mapping, acc), do: :maps.from_list(acc)
defp do_rename_keys([{key, value} | rest], keys_mapping, acc) do
item =
case keys_mapping do
%{^key => new_key} -> {new_key, value}
%{} -> {key, value}
end
do_rename_keys(rest, keys_mapping, [item | acc])
end
end