Skip to content

Commit

Permalink
📎 smart coerce with split: true
Browse files Browse the repository at this point in the history
  • Loading branch information
Aleksei Matiushkin committed Feb 4, 2024
1 parent 047e24c commit a5ccae9
Show file tree
Hide file tree
Showing 5 changed files with 90 additions and 25 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ end
I suggest adding [`boundary`](https://hexdocs.pm/boundary) as a dependency since that is used in this project.

## Changelog
* `1.2.0` — `Estructura.Nested` would attempt to split keys by a delimiter if instructed
* `1.1.0` — `Estructura.Tree` to hold an AST structure, like XML
* `1.0.0` — Elixir v1.16 and deps
* `0.6.0` — `Estructura.Transform` to produce squeezed representations of nested structs
Expand Down
5 changes: 3 additions & 2 deletions lib/estructura.ex
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,9 @@ defmodule Estructura do
@doc """
Instantiates the struct by using `Access` from a map, passing all coercions and validations.
"""
@spec coerce(module(), map()) :: {:ok, struct()} | {:error, Exception.t()}
def coerce(module, %{} = map) when is_atom(module), do: Estructura.Nested.from_term(module, map)
@spec coerce(module(), map(), keyword()) :: {:ok, struct()} | {:error, Exception.t()}
def coerce(module, %{} = map, options \\ []) when is_atom(module),
do: Estructura.Nested.from_term(module, map, options)

@spec diff_result({map(), map()}, :overlap | :disjoint) :: map()
defp diff_result({same, diff}, :overlap),
Expand Down
76 changes: 54 additions & 22 deletions lib/estructura/nested.ex
Original file line number Diff line number Diff line change
Expand Up @@ -152,21 +152,25 @@ defmodule Estructura.Nested do
end

@doc false
@spec from_term(module(), map() | [map()]) :: {:ok, struct()} | {:error, Exception.t()}
def from_term(module, list) when is_list(list),
do: Enum.map(list, &from_term(module, &1))
@spec from_term(module(), map() | [map()], keyword()) ::
{:ok, struct()} | {:error, Exception.t()}
def from_term(module, map, options \\ [])

def from_term(module, %{} = map) do
{result, [], errors} = do_from_map({struct!(module, []), [], []}, map)
def from_term(module, list, options) when is_list(list),
do: Enum.map(list, &from_term(module, &1, options))

def from_term(module, %{} = map, options) do
{result, [], errors} = do_from_map({struct!(module, []), [], []}, map, options)

case errors do
[] -> {:ok, result}
errors -> {:error, squeeze(errors, %KeyError{key: [], term: module})}
end
end

defp atomize(key) when is_list(key), do: Enum.map(key, &atomize/1)
defp atomize(key) when is_atom(key), do: key
defp atomize(key) when is_binary(key), do: String.to_atom(key)
defp atomize(key) when is_binary(key), do: String.to_existing_atom(key)

defp squeeze([], acc), do: acc

Expand All @@ -187,29 +191,55 @@ defmodule Estructura.Nested do
defp trim_left([list, []]), do: list
defp trim_left([[h | list], [h | lead]]), do: trim_left([list, lead])

@spec do_from_map({struct(), [atom()], [Exception.t()]}, map()) ::
@spec do_from_map({struct(), [atom()], [Exception.t()]}, map(), keyword()) ::
{struct(), [atom()], [Exception.t()]}
defp do_from_map(acc, map) do
defp do_from_map(acc, map, options) do
Enum.reduce(map, acc, fn
{key, %{} = map}, {into, path, errors} ->
{into, [_ | path], errors} = do_from_map({into, [atomize(key) | path], errors}, map)
{into, [_ | path], errors} =
do_from_map({into, [atomize(key) | path], errors}, map, options)

{into, path, errors}

{key, value}, {into, path, errors} ->
key_path = Enum.reverse([atomize(key) | path])
key = to_string(key)

{delim, num} =
case Keyword.get(options, :split, false) do
false -> {"_", 1}
true -> {"_", -1}
num when is_integer(num) and num > 1 -> {"_", num}
delim when is_binary(delim) -> {delim, -1}
{delim, num} when is_binary(delim) and is_integer(num) and num > 1 -> {delim, num}
end

num = if num < 1, do: Enum.count(String.split(key, delim)), else: num
key_paths = Enum.map(1..num//1, &String.split(key, "_", parts: &1))

Enum.reduce_while(key_paths, {false, into}, fn key_path, {false, into} ->
try do
key_path = Enum.reverse(path) ++ atomize(key_path)
{:halt, {true, put_in(into, key_path, value)}}
rescue
_e in [ArgumentError, KeyError] -> {:cont, {false, into}}
end
end)
|> case do
{true, into} ->
{into, path, errors}

{false, into} ->
key = [key | path] |> Enum.reverse() |> Enum.join(".")

try do
{put_in(into, key_path, value), path, errors}
rescue
e in [ArgumentError] ->
{into, path,
[
%KeyError{message: e.message, key: Enum.join(key_path, "."), term: into.__struct__}
%KeyError{
message: "Unknown key in nested struct: ‹#{key}›",
key: key,
term: into.__struct__
}
| errors
]}

e in [KeyError] ->
{into, path, [e | errors]}
end
end)
end
Expand Down Expand Up @@ -365,13 +395,15 @@ defmodule Estructura.Nested do
@doc """
Casts the map representation as given to `Estructura.Nested.shape/1` to
the nested `Estructura` instance.
If `split: true` is passed as an option, it will attempt to put `foo_bar` into nested `%{foo: %{bar: _}}`
"""
def cast(%{} = content),
do: Estructura.Nested.from_term(unquote(module), content)
def cast(%{} = content, options \\ []),
do: Estructura.Nested.from_term(unquote(module), content, options)

def cast!(%{} = content) do
def cast!(%{} = content, options \\ []) do
content
|> cast()
|> cast(options)
|> case do
{:ok, cast} -> cast
{:error, error} -> raise error
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ defmodule Estructura.MixProject do
use Mix.Project

@app :estructura
@version "1.1.1"
@version "1.2.0"

def project do
[
Expand Down
31 changes: 31 additions & 0 deletions test/estructura/nested_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,35 @@ defmodule Estructura.Nested.Test do
assert Enum.sort(key) == ["address.ciudad", "address.street.casa", "address.street.nombre"]
end
end

property "Guessed Casting" do
check all %User{} = user <- User.__generator__() do
raw_user_ok = %{
name: user.name,
address_city: user.address.city,
address_street_name: user.address.street.name,
address_street_house: user.address.street.house,
data_age: user.data.age
}

assert {:error, %KeyError{}} = User.cast(raw_user_ok)
assert {:ok, ^user} = User.cast(raw_user_ok, split: true)

raw_user_ko = %{
"name" => user.name,
:addresscity => user.address.city,
"address_street_nombre" => user.address.street.name,
"address_street_casa" => user.address.street.house,
:data_age => user.data.age
}

assert {:error,
%KeyError{
key: key,
term: Estructura.User
}} = User.cast(raw_user_ko, split: true)

assert Enum.sort(key) == ["address_street_casa", "address_street_nombre", "addresscity"]
end
end
end

0 comments on commit a5ccae9

Please sign in to comment.