Packages

Deterministic helpers for traversing nested maps with explicit key paths.

Current section

Files

Jump to
path_map lib path_map.ex
Raw

lib/path_map.ex

defmodule PathMap do
@moduledoc """
PathMap provides deterministic helpers for traversing and mutating nested maps
using explicit *paths* (lists of keys).
Paths can be empty (`[]`, meaning the root map) or lists like `[:a, "b"]`.
Keys are not restricted to atoms.
Every public function validates inputs and returns tagged errors instead of
raising. The root is checked first, so a non-map root yields
`{:error, {:not_a_map, root, []}}` even if the path is invalid.
The API comes in two families:
- strict (`put/3`, `put_new/3`, `update/3`, etc.) require each path segment to exist
- auto-vivifying (`put_auto/3`, `put_new_auto/3`, `update_auto/4`) create
missing maps on the way
Error tuples you may see:
- `:invalid_path` when the path is not a list
- `{:not_a_map, value, prefix}` when traversal hits a non-map at `prefix`
- `{:missing, prefix}` when a strict operation needs a missing key
- `:already_exists` when `put_new*/3` refuses to overwrite
- `{:invalid_function, fun, arity}` and `{:invalid_initializer, init}` when
callbacks have the wrong shape
- `:leaf_missing` when `update/3` expects a leaf that is not present
## Examples
iex> map = %{"config" => %{"port" => 4000}}
iex> PathMap.fetch(map, ["config", "port"])
{:ok, 4000}
iex> map = %{"config" => %{"port" => 4000}}
iex> PathMap.put(map, ["config", "db", "port"], 5432)
{:error, {:missing, ["config", "db"]}}
iex> {:ok, with_db} = PathMap.put_auto(map, ["config", "db", "port"], 5432)
iex> with_db["config"]["db"]["port"]
5432
iex> PathMap.get(%{"config" => 1}, :bad_path, :fallback)
:fallback
iex> PathMap.update_auto(%{"config" => "not a map"}, ["config", "port"], 4000, & &1)
{:error, {:not_a_map, "not a map", ["config"]}}
"""
@type key :: term()
@type val :: term()
@typedoc """
`path` is an ordered list of keys that represents a location within `PathMap`.
Can be empty `[]` or non-empty `[key | rest]`.
"""
@type path :: list(key())
@typedoc """
`PathMap` is a nested map, where values can be arbitrary terminal values or
other maps (subtrees).
`PathMap` does not enforce homogeneity of values.
"""
@type t :: %{key() => val()}
@type err_not_a_map :: {:error, {:not_a_map, term(), path()}}
@type err_missing :: {:error, {:missing, path()}}
@type err_invalid_path :: {:error, :invalid_path}
@type err_invalid_fun :: {:error, {:invalid_function, term(), arity :: non_neg_integer()}}
@type err_invalid_initializer :: {:error, {:invalid_initializer, term()}}
# SECTION - Read API
@doc """
Fetches a value from `map` at `path`.
`path` must be a list; the empty list returns the full map. The root being a
non-map is reported before path validation.
Returns:
- `{:ok, value}` when the path can be traversed
- `{:error, :invalid_path}` when `path` is not a list
- `{:error, {:missing, prefix}}` when any segment does not exist
- `{:error, {:not_a_map, val, prefix}}` when a non-map is encountered
## Examples
iex> PathMap.fetch(%{a: %{b: 1}}, [:a, :b])
{:ok, 1}
iex> PathMap.fetch(%{a: %{b: 1}}, [])
{:ok, %{a: %{b: 1}}}
iex> PathMap.fetch(%{a: 1}, [:a, :b])
{:error, {:not_a_map, 1, [:a]}}
iex> PathMap.fetch(%{}, :not_a_list)
{:error, :invalid_path}
iex> PathMap.fetch(:root_is_wrong, [])
{:error, {:not_a_map, :root_is_wrong, []}}
"""
@spec fetch(t(), path()) ::
{:error, {:not_a_map, val(), path()} | {:missing, path()} | :invalid_path}
| {:ok, val()}
def fetch(map, _path) when not is_map(map), do: {:error, {:not_a_map, map, []}}
def fetch(_map, path) when not is_list(path), do: {:error, :invalid_path}
def fetch(map, path), do: fetch_nested(map, path, [])
defp fetch_nested(map, [], _acc) when is_map(map), do: {:ok, map}
defp fetch_nested(map, [key], acc) when is_map(map) do
case Map.fetch(map, key) do
{:ok, val} -> {:ok, val}
:error -> {:error, {:missing, Enum.reverse(acc, [key])}}
end
end
defp fetch_nested(map, [key | rest], acc) when is_map(map) do
case Map.fetch(map, key) do
{:ok, val} -> fetch_nested(val, rest, [key | acc])
:error -> {:error, {:missing, Enum.reverse(acc, [key])}}
end
end
defp fetch_nested(val, _path, acc), do: {:error, {:not_a_map, val, Enum.reverse(acc)}}
@doc """
Gets a value from `map` by its `path`.
Returns the provided `default` (nil by default) on *any* error, including a
non-map root or an invalid path type.
## Examples
iex> PathMap.get(%{a: %{b: 1}}, [:a, :b])
1
iex> PathMap.get(%{}, [:missing], :none)
:none
iex> PathMap.get(:not_a_map, [:a], :none)
:none
"""
@spec get(t(), path(), default) :: val() | default when default: term()
def get(map, path, default \\ nil) do
case fetch(map, path) do
{:ok, val} -> val
{:error, _} -> default
end
end
@doc """
Checks if a given `path` exists in `map`.
Delegates to `fetch/2` and collapses any error into `false`, including
invalid path types or non-map roots.
## Examples
iex> PathMap.exists?(%{a: %{b: 1}}, [:a, :b])
true
iex> PathMap.exists?(%{}, :bad_path)
false
"""
@spec exists?(t(), path()) :: boolean()
def exists?(map, path) do
case fetch(map, path) do
{:ok, _} -> true
{:error, _} -> false
end
end
@doc """
Validates a given `path` of `map`.
Thin wrapper around `fetch/2` that returns `:ok` on success or the same error
tuple as `fetch/2` on failure.
## Examples
iex> PathMap.validate_path(%{a: %{b: 1}}, [:a, :b])
:ok
iex> PathMap.validate_path(%{}, [:missing])
{:error, {:missing, [:missing]}}
"""
@spec validate_path(t(), path()) ::
:ok
| {:error, {:not_a_map, val(), path()} | {:missing, path()} | :invalid_path}
def validate_path(map, path) do
case fetch(map, path) do
{:ok, _} -> :ok
e -> e
end
end
@doc """
Boolean version of `validate_path/2`.
Returns `true` when the path can be traversed, `false` otherwise.
## Examples
iex> PathMap.valid_path?(%{a: 1}, [:a])
true
iex> PathMap.valid_path?(%{}, [:a, :b])
false
"""
@spec valid_path?(t(), path()) :: boolean()
def valid_path?(map, path) do
case validate_path(map, path) do
:ok -> true
{:error, _} -> false
end
end
#!SECTION - Read API
# SECTION - Write API
@doc """
Permissive insertion.
Put `val` at `path` into `map`, auto-vivifying intermediate maps. An empty
path replaces the entire map. Encountering a non-map stops traversal with
`{:error, {:not_a_map, val, prefix}}`; root type check runs before path
validation.
Errors:
- `{:error, :invalid_path}` if `path` is not a list
- `{:error, {:not_a_map, val, prefix}}` if an intermediate subtree at `prefix` is not a map
## Examples
iex> PathMap.put_auto(%{}, [:a, :b], 2)
{:ok, %{a: %{b: 2}}}
iex> PathMap.put_auto(%{a: 1}, [:a, :b], 2)
{:error, {:not_a_map, 1, [:a]}}
iex> PathMap.put_auto(:oops, [:a], 1)
{:error, {:not_a_map, :oops, []}}
"""
@spec put_auto(t(), path(), val()) ::
{:ok, t()}
| {:error, :invalid_path | {:not_a_map, val(), path()}}
def put_auto(map, _path, _val) when not is_map(map), do: {:error, {:not_a_map, map, []}}
def put_auto(_map, path, _val) when not is_list(path), do: {:error, :invalid_path}
def put_auto(map, path, val), do: put_auto_nested(map, path, val, [])
# replace entire state with val
defp put_auto_nested(map, [], val, _acc) when is_map(map), do: {:ok, val}
# fast path
defp put_auto_nested(map, [key], val, _acc) when is_map(map) do
{:ok, Map.put(map, key, val)}
end
# normal path
defp put_auto_nested(map, [key | rest], val, acc) when is_map(map) do
case Map.fetch(map, key) do
{:ok, next} when is_map(next) ->
case put_auto_nested(next, rest, val, [key | acc]) do
{:ok, updated_next} -> {:ok, Map.put(map, key, updated_next)}
{:error, _} = error -> error
end
{:ok, not_a_map} ->
{:error, {:not_a_map, not_a_map, Enum.reverse(acc, [key])}}
:error ->
{:ok, updated_next} = put_auto_nested(%{}, rest, val, [key | acc])
{:ok, Map.put(map, key, updated_next)}
end
end
@doc """
Strict insertion.
Put `val` at `path` into `map`, failing if any part of the path is missing.
`path == []` replaces the entire `map` with `val`. Missing segments return
`{:error, {:missing, prefix}}`; encountering a non-map returns
`{:error, {:not_a_map, val, prefix}}`.
Errors:
- `{:error, :invalid_path}` if `path` is not a list
- `{:error, {:not_a_map, val, prefix}}` if the traversal encounters a non-map at `prefix`
- `{:error, {:missing, prefix}}` if `prefix` in `path` does not exist
## Examples
iex> PathMap.put(%{a: %{b: 1}}, [:a, :b], 2)
{:ok, %{a: %{b: 2}}}
iex> PathMap.put(%{}, [:a, :b], 1)
{:error, {:missing, [:a]}}
iex> PathMap.put(%{a: 1}, [:a, :b], 2)
{:error, {:not_a_map, 1, [:a]}}
iex> PathMap.put(%{a: 1}, [], :new)
{:ok, :new}
"""
@spec put(t(), path(), val()) ::
{:ok, t()}
| {:error, :invalid_path | {:not_a_map, val(), path()} | {:missing, path()}}
def put(map, _path, _val) when not is_map(map), do: {:error, {:not_a_map, map, []}}
def put(_map, path, _val) when not is_list(path), do: {:error, :invalid_path}
def put(map, path, val), do: put_nested(map, path, val, [])
defp put_nested(map, [], val, _acc) when is_map(map), do: {:ok, val}
defp put_nested(map, [key], val, _acc) when is_map(map) do
{:ok, Map.put(map, key, val)}
end
defp put_nested(map, [key | rest], val, acc) when is_map(map) do
case Map.fetch(map, key) do
{:ok, next} ->
case put_nested(next, rest, val, [key | acc]) do
{:ok, updated_next} -> {:ok, Map.put(map, key, updated_next)}
{:error, _} = error -> error
end
:error ->
{:error, {:missing, Enum.reverse(acc, [key])}}
end
end
defp put_nested(not_a_map, _path, _val, acc),
do: {:error, {:not_a_map, not_a_map, Enum.reverse(acc)}}
@doc """
Put a new element at `path` with `val` without overwriting existing data.
Traverses strictly (no auto-vivification). Missing intermediates yield
`{:error, {:missing, prefix}}` and hitting a non-map yields
`{:error, {:not_a_map, val, prefix}}`. Passing `[]` returns
`{:error, :already_exists}`.
## Examples
iex> PathMap.put_new(%{a: %{b: 1}}, [:a, :c], 2)
{:ok, %{a: %{b: 1, c: 2}}}
iex> PathMap.put_new(%{a: %{b: 1}}, [:a, :b], 2)
{:error, :already_exists}
iex> PathMap.put_new(%{}, [:a, :b], 1)
{:error, {:missing, [:a]}}
"""
@spec put_new(t(), path(), val()) ::
{:ok, t()}
| err_not_a_map()
| err_invalid_path()
| err_missing()
| {:error, :already_exists}
def put_new(map, _path, _val) when not is_map(map), do: {:error, {:not_a_map, map, []}}
def put_new(_map, path, _val) when not is_list(path), do: {:error, :invalid_path}
def put_new(map, path, val), do: put_new_nested(map, path, val, [])
defp put_new_nested(map, [], _val, _acc) when is_map(map), do: {:error, :already_exists}
defp put_new_nested(map, [key], val, _acc) when is_map(map) do
case Map.fetch(map, key) do
:error -> {:ok, Map.put(map, key, val)}
{:ok, _} -> {:error, :already_exists}
end
end
defp put_new_nested(map, [key | rest], val, acc) when is_map(map) do
case Map.fetch(map, key) do
{:ok, next} ->
case put_new_nested(next, rest, val, [key | acc]) do
{:ok, updated_next} -> {:ok, Map.put(map, key, updated_next)}
{:error, _} = error -> error
end
:error ->
{:error, {:missing, Enum.reverse(acc, [key])}}
end
end
defp put_new_nested(not_a_map, _path, _val, acc),
do: {:error, {:not_a_map, not_a_map, Enum.reverse(acc)}}
@doc """
Put a new element at `path` with `val`, auto-vivifying missing maps.
Returns `{:error, :already_exists}` when the leaf already exists. Encountering
an existing non-map still returns `{:error, {:not_a_map, val, prefix}}`.
Passing `[]` returns `{:error, :already_exists}`.
## Examples
iex> PathMap.put_new_auto(%{}, [:a, :b, :c], 3)
{:ok, %{a: %{b: %{c: 3}}}}
iex> PathMap.put_new_auto(%{a: %{b: 1}}, [:a, :b], 2)
{:error, :already_exists}
iex> PathMap.put_new_auto(%{a: 1}, [:a, :b], 2)
{:error, {:not_a_map, 1, [:a]}}
"""
@spec put_new_auto(t(), path(), val()) ::
{:ok, t()}
| err_not_a_map()
| err_invalid_path()
| {:error, :already_exists}
def put_new_auto(map, _path, _val) when not is_map(map), do: {:error, {:not_a_map, map, []}}
def put_new_auto(_map, path, _val) when not is_list(path), do: {:error, :invalid_path}
def put_new_auto(map, path, val), do: put_new_auto_nested(map, path, val, [])
defp put_new_auto_nested(map, [], _val, _acc) when is_map(map),
do: {:error, :already_exists}
defp put_new_auto_nested(map, [key], val, _acc) when is_map(map) do
case Map.fetch(map, key) do
:error -> {:ok, Map.put(map, key, val)}
{:ok, _} -> {:error, :already_exists}
end
end
defp put_new_auto_nested(map, [key | rest], val, acc) when is_map(map) do
case Map.fetch(map, key) do
{:ok, next} when is_map(next) ->
case put_new_auto_nested(next, rest, val, [key | acc]) do
{:ok, updated_next} -> {:ok, Map.put(map, key, updated_next)}
{:error, _} = error -> error
end
{:ok, not_a_map} ->
{:error, {:not_a_map, not_a_map, Enum.reverse(acc, [key])}}
:error ->
{:ok, updated_next} = put_new_auto_nested(%{}, rest, val, [key | acc])
{:ok, Map.put(map, key, updated_next)}
end
end
@doc """
Initialize an element at `path` with `initializer` function if it doesn't exist.
Traverses the path without auto-vivifying, leaving the existing value intact
when the element already exists. `initializer` must be a 0-arity function and
is only executed when the leaf is missing. `path == []` is a no-op that
returns `{:ok, map}`.
Errors:
- `{:error, :invalid_path}` if `path` is not a list
- `{:error, {:not_a_map, val, prefix}}` if a non-map is encountered on the way
- `{:error, {:missing, prefix}}` if part of the path does not exist
- `{:error, {:invalid_initializer, initializer}}` if initializer is not a 0-arity function
## Examples
iex> PathMap.ensure(%{a: %{b: 1}}, [:a, :b], fn -> 0 end)
{:ok, %{a: %{b: 1}}}
iex> PathMap.ensure(%{a: %{}}, [:a, :b], fn -> 2 end)
{:ok, %{a: %{b: 2}}}
iex> PathMap.ensure(%{}, [:a, :b], fn -> 2 end)
{:error, {:missing, [:a]}}
"""
@spec ensure(t(), path(), (-> val())) ::
{:ok, t()}
| err_not_a_map()
| err_invalid_path()
| err_missing()
| err_invalid_initializer()
def ensure(map, _path, _initializer) when not is_map(map),
do: {:error, {:not_a_map, map, []}}
def ensure(_map, path, _initializer) when not is_list(path), do: {:error, :invalid_path}
def ensure(_map, _path, initializer) when not is_function(initializer, 0),
do: {:error, {:invalid_initializer, initializer}}
def ensure(map, path, initializer), do: ensure_nested(map, path, initializer, [])
defp ensure_nested(map, [], _initializer, _acc) when is_map(map), do: {:ok, map}
defp ensure_nested(map, [key], initializer, _acc) when is_map(map) do
case Map.fetch(map, key) do
{:ok, _} -> {:ok, map}
:error -> {:ok, Map.put(map, key, initializer.())}
end
end
defp ensure_nested(map, [key | rest], initializer, acc) when is_map(map) do
case Map.fetch(map, key) do
{:ok, next} ->
case ensure_nested(next, rest, initializer, [key | acc]) do
{:ok, updated_next} -> {:ok, Map.put(map, key, updated_next)}
{:error, _} = error -> error
end
:error ->
{:error, {:missing, Enum.reverse(acc, [key])}}
end
end
defp ensure_nested(not_a_map, _path, _initializer, acc),
do: {:error, {:not_a_map, not_a_map, Enum.reverse(acc)}}
@doc """
Update an element at `path` with `function`.
Traverses strictly (no auto-vivification). Returns `{:error, :leaf_missing}`
when the terminal key is absent even if intermediates exist. `path == []`
applies `function` to the entire map.
Errors:
- `{:error, :invalid_path}` if `path` is not a list
- `{:error, {:invalid_function, fun, 1}}` if `function` is not arity-1
- `{:error, {:missing, prefix}}` if an intermediate segment is missing
- `{:error, {:not_a_map, val, prefix}}` if an intermediate value is not a map
- `{:error, :leaf_missing}` if the final key is missing while intermediates exist
## Examples
iex> PathMap.update(%{a: 1}, [:a], &(&1 + 1))
{:ok, %{a: 2}}
iex> PathMap.update(%{a: %{}}, [:a, :b], &(&1 + 1))
{:error, :leaf_missing}
iex> PathMap.update(%{a: 1}, [], &Map.put(&1, :b, 2))
{:ok, %{a: 1, b: 2}}
"""
@spec update(t(), path(), (val() -> val())) ::
{:ok, t()}
| err_not_a_map()
| err_invalid_path()
| err_missing()
| {:error, :leaf_missing}
| err_invalid_fun()
def update(map, _path, _function) when not is_map(map),
do: {:error, {:not_a_map, map, []}}
def update(_map, path, _function) when not is_list(path), do: {:error, :invalid_path}
def update(_map, _path, function) when not is_function(function, 1),
do: {:error, {:invalid_function, function, 1}}
def update(map, path, function), do: update_nested(map, path, function, [])
defp update_nested(map, [], function, _acc) when is_map(map), do: {:ok, function.(map)}
defp update_nested(map, [key], function, _acc) when is_map(map) do
case Map.fetch(map, key) do
{:ok, val} -> {:ok, Map.put(map, key, function.(val))}
:error -> {:error, :leaf_missing}
end
end
defp update_nested(map, [key | rest], function, acc) when is_map(map) do
case Map.fetch(map, key) do
{:ok, next} ->
case update_nested(next, rest, function, [key | acc]) do
{:ok, updated_next} -> {:ok, Map.put(map, key, updated_next)}
{:error, _} = error -> error
end
:error ->
{:error, {:missing, Enum.reverse(acc, [key])}}
end
end
defp update_nested(not_a_map, _path, _function, acc),
do: {:error, {:not_a_map, not_a_map, Enum.reverse(acc)}}
@doc """
Update an element at `path` with `function`, inserting `default` when missing.
Traverses strictly (no auto-vivification). Missing intermediate segments
return `{:error, {:missing, prefix}}`; encountering a non-map returns
`{:error, {:not_a_map, val, prefix}}`. When the final key is absent but the
path exists so far, it is set to `default` without calling `function`. An
empty path applies `function` to the root map.
Errors:
- `{:error, :invalid_path}` if `path` is not a list
- `{:error, {:invalid_function, fun, 1}}` if `function` is not arity-1
- `{:error, {:not_a_map, val, prefix}}` or `{:error, {:missing, prefix}}` for traversal issues
## Examples
iex> PathMap.update(%{a: %{b: 1}}, [:a, :b], 0, &(&1 + 1))
{:ok, %{a: %{b: 2}}}
iex> PathMap.update(%{a: %{}}, [:a, :b], 5, &(&1 + 1))
{:ok, %{a: %{b: 5}}}
iex> PathMap.update(%{}, [:a, :b], 0, &(&1 + 1))
{:error, {:missing, [:a]}}
"""
@spec update(t(), path(), val(), (val() -> val())) ::
{:ok, t()} | err_not_a_map() | err_invalid_path() | err_missing() | err_invalid_fun()
def update(map, _path, _default, _function) when not is_map(map),
do: {:error, {:not_a_map, map, []}}
def update(_map, path, _default, _function) when not is_list(path),
do: {:error, :invalid_path}
def update(_map, _path, _default, function) when not is_function(function, 1),
do: {:error, {:invalid_function, function, 1}}
def update(map, path, default, function),
do: update_with_default_nested(map, path, default, function, [])
defp update_with_default_nested(map, [], _default, function, _acc) when is_map(map),
do: {:ok, function.(map)}
defp update_with_default_nested(map, [key], default, function, _acc) when is_map(map) do
case Map.fetch(map, key) do
{:ok, val} -> {:ok, Map.put(map, key, function.(val))}
:error -> {:ok, Map.put(map, key, default)}
end
end
defp update_with_default_nested(map, [key | rest], default, function, acc) when is_map(map) do
case Map.fetch(map, key) do
{:ok, next} ->
case update_with_default_nested(next, rest, default, function, [key | acc]) do
{:ok, updated_next} -> {:ok, Map.put(map, key, updated_next)}
{:error, _} = error -> error
end
:error ->
{:error, {:missing, Enum.reverse(acc, [key])}}
end
end
defp update_with_default_nested(not_a_map, _path, _default, _function, acc),
do: {:error, {:not_a_map, not_a_map, Enum.reverse(acc)}}
@doc """
Update an element at `path` with `function`, auto-vivifying missing maps.
Missing leaves are initialized to `default` and then passed to `function`.
Missing intermediates are created as `%{}`. `path == []` applies `function` to
the root map. Fails when the root or an encountered value is not a map, when
the path is not a list, or when `function` is not arity-1.
## Examples
iex> PathMap.update_auto(%{}, [:a, :b], 0, &(&1 + 1))
{:ok, %{a: %{b: 1}}}
iex> PathMap.update_auto(%{a: %{b: 2}}, [:a, :b], 0, &(&1 + 1))
{:ok, %{a: %{b: 3}}}
iex> PathMap.update_auto(%{a: 1}, [:a, :b], 0, & &1)
{:error, {:not_a_map, 1, [:a]}}
"""
@spec update_auto(t(), path(), val(), (val() -> val())) ::
{:ok, t()} | err_not_a_map() | err_invalid_path() | err_invalid_fun()
def update_auto(map, _path, _default, _function) when not is_map(map),
do: {:error, {:not_a_map, map, []}}
def update_auto(_map, path, _default, _function) when not is_list(path),
do: {:error, :invalid_path}
def update_auto(_map, _path, _default, function) when not is_function(function, 1),
do: {:error, {:invalid_function, function, 1}}
def update_auto(map, path, default, function),
do: update_auto_nested(map, path, default, function, [])
defp update_auto_nested(map, [], _default, function, _acc) when is_map(map),
do: {:ok, function.(map)}
defp update_auto_nested(map, [key], default, function, _acc) when is_map(map) do
next = Map.get(map, key, default)
{:ok, Map.put(map, key, function.(next))}
end
defp update_auto_nested(map, [key | rest], default, function, acc) when is_map(map) do
case Map.fetch(map, key) do
{:ok, next} when is_map(next) ->
case update_auto_nested(next, rest, default, function, [key | acc]) do
{:ok, updated_next} -> {:ok, Map.put(map, key, updated_next)}
{:error, _} = error -> error
end
{:ok, not_a_map} ->
{:error, {:not_a_map, not_a_map, Enum.reverse(acc, [key])}}
:error ->
{:ok, updated_next} = update_auto_nested(%{}, rest, default, function, [key | acc])
{:ok, Map.put(map, key, updated_next)}
end
end
#!SECTION - Write API
end