Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions lib/internal/map_helpers.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
defmodule AbsintheUtils.Internal.MapHelpers do
@moduledoc """
Helper for nested map manipulations.
"""

@doc """
Pop a key from a nested map, if successful returns a tuple of:
- the popped value
- the modified map, without the popped key
OR if the key is not found, returns :error

## Examples

iex> MapHelpers.safe_pop_in(%{a: 1}, [:a])
{1, %{}}

iex> MapHelpers.safe_pop_in(%{a: 1}, :a)
{1, %{}}

iex> MapHelpers.safe_pop_in(%{a: 1}, [:invalid])
:error

iex> MapHelpers.safe_pop_in(%{a: %{b: 1}}, [:a, :b])
{1, %{a: %{}}}

iex> MapHelpers.safe_pop_in(%{a: %{b: 1}}, [:a, :invalid])
:error

iex> MapHelpers.safe_pop_in(%{}, [:a])
:error

iex> MapHelpers.safe_pop_in(%{a: 1}, [])
:error

iex> MapHelpers.safe_pop_in(%{a: 1}, [:a, :b, :c])
:error

iex> MapHelpers.safe_pop_in(%{a: 1, b: %{c: 2}}, [:b])
{%{c: 2}, %{a: 1}}

iex> MapHelpers.safe_pop_in(%{"a" => 1}, ["a"])
{1, %{}}
"""
def safe_pop_in(map, [last_key]) do
safe_pop_in(map, last_key)
end

def safe_pop_in(map, [key | rest]) when is_map(map) do
case Map.fetch(map, key) do
{:ok, value} ->
case safe_pop_in(value, rest) do
{popped_value, new_value} ->
{popped_value, Map.put(map, key, new_value)}

:error ->
:error
end

:error ->
:error
end
end

def safe_pop_in(map, last_key) when is_map_key(map, last_key) do
Map.pop(map, last_key)
end

def safe_pop_in(_, _) do
:error
end

@doc """
Put a value in a tested map, if any of the keys in the keys_path are not found,
they will be recursively created.

## Examples

iex> MapHelpers.recursive_put_in(%{a: 1}, [:b], 2)
%{a: 1, b: 2}

iex> MapHelpers.recursive_put_in(%{}, [:a], 1)
%{a: 1}

iex> MapHelpers.recursive_put_in(%{}, :a, 1)
%{a: 1}
"""

def recursive_put_in(map, keys_path, _value) when not is_map(map) do
raise ArgumentError,
"Cannot put value recursively #{inspect(keys_path)} into: #{inspect(map)}"
end

def recursive_put_in(map, keys_path, value) when is_atom(keys_path) do
recursive_put_in(map, [keys_path], value)
end

def recursive_put_in(map, [key], value) do
Map.put(map, key, value)
end

def recursive_put_in(map, [key | rest], value) do
current_value = Map.get(map, key, %{})
updated_value = recursive_put_in(current_value, rest, value)
Map.put(map, key, updated_value)
end
end
148 changes: 115 additions & 33 deletions lib/middleware/arg_loader.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ defmodule AbsintheUtils.Middleware.ArgLoader do
This middleware should be defined before `resolve`. It will manipulate the arguments
before they are passed to the resolver function.

As configuration it accepts a map of original argument names to a keyword list, containing:
As configuration it accepts a map of original argument names (or path) to a keyword list, containing:

- `new_name`: the new key to push the loaded entity into, can be a list of atoms
to push the entity into a nested map.
(optional, defaults to the argument name - the key of the map configuration).
- `load_function`: the function used to load the argument into an entity.
As an input accepts one single argument: the input received in the resolution.
The function should return the entity, or `nil` when not found.
- `new_name`: the new name to push the loaded entity into.
(optional, defaults to the original argument name).
As an input accepts two arguments:
- `context`: the context of the current resolution (prior to any modifications of the current middleware).
- `input_value`: the value received in the value of `argument_name`.
The function should return the entity or a list of entities.
`nil` or an empty list when not found .
- `nil_is_not_found`: whether to consider `nil` as a not found value.
(optional, defaults to `true`).

## Examples

Expand All @@ -26,7 +32,10 @@ defmodule AbsintheUtils.Middleware.ArgLoader do
%{
id: [
new_name: :user,
load_function: &get_user_by_id/1
load_function: fn _context, id ->
get_user_by_id(id)
end,
nil_is_not_found: false
]
}
)
Expand All @@ -38,7 +47,8 @@ defmodule AbsintheUtils.Middleware.ArgLoader do
```

This will define a `user` query that accepts an `id` input. Before calling the resolver,
it will load the user via `get_user_by_id(id)` into the `user` argument, removing `id`.

### List of entities

`ArgLoader` can also be used to load a `list_of` arguments:

Expand All @@ -52,7 +62,7 @@ defmodule AbsintheUtils.Middleware.ArgLoader do
%{
ids: [
new_name: :users,
load_function: fn ids ->
load_function: fn _context, ids ->
ids
|> get_users_by_id()
|> AbsintheUtils.Helpers.Sorting.sort_alike(ids, & &1.id)
Expand All @@ -68,13 +78,48 @@ defmodule AbsintheUtils.Middleware.ArgLoader do
end
```

Note the use of ` AbsintheUtils.Helpers.Sorting.sort_alike/2` to ensure the returned list of
### Deep nested arguments

Using `ArgLoader` to load and rename deep nested arguments:

```
input_object :complex_input_object do
field(:user_id, :id)
end

query do
field :users, :boolean do
arg(:input, :complex_input_object)

middleware(
ArgLoader,
%{
[:input, :user_id] => [
new_name: [:loaded_entities, :user],
load_function: fn _context, user_id ->
get_user_by_id(user_id)
end
]
}
)

resolve(fn _, params, _ ->
# params.loaded_entities.user will contain the loaded user
{:ok, true}
end)
end
end
```


Note the use of `AbsintheUtils.Helpers.Sorting.sort_alike/2` to ensure the returned list of
entities from the repository is sorted according to the user's input.
"""

@behaviour Absinthe.Middleware

alias AbsintheUtils.Helpers.Errors
alias AbsintheUtils.Internal.MapHelpers

@impl true
def call(
Expand All @@ -85,11 +130,12 @@ defmodule AbsintheUtils.Middleware.ArgLoader do
opts
|> Enum.reduce(
{arguments, []},
fn {argument_name, opts}, {arguments, not_found_arguments} ->
fn {argument_name, argument_opts}, {arguments, not_found_arguments} ->
case load_entities(
resolution,
arguments,
argument_name,
opts
argument_opts
) do
:not_found ->
{
Expand All @@ -112,46 +158,82 @@ defmodule AbsintheUtils.Middleware.ArgLoader do
| arguments: arguments
}
else
arg_names =
not_found_arguments
|> Enum.map_join(", ", fn arg_name ->
arg_name
|> to_string()
|> Absinthe.Utils.camelize(lower: true)
end)

Errors.put_error(
resolution,
"The entity(ies) provided in the following arg(s), could not be found: " <>
Enum.join(not_found_arguments, ", "),
"The entity(ies) provided in the following arg(s), could not be found: #{arg_names}",
"NOT_FOUND"
)
end
end

def load_entities(arguments, argument_name, opts)
when is_map_key(arguments, argument_name) do
load_function = Keyword.fetch!(opts, :load_function)
push_to_key = Keyword.get(opts, :new_name, argument_name)
defp load_entities(original_resolution, arguments, argument_name, opts)
when is_atom(argument_name) do
load_entities(original_resolution, arguments, [argument_name], opts)
end

{input_value, arguments} = Map.pop!(arguments, argument_name)
defp load_entities(original_resolution, arguments, argument_keys_path, opts)
when is_list(argument_keys_path) do
case MapHelpers.safe_pop_in(arguments, argument_keys_path) do
{argument_value, popped_arguments} ->
case call_load_function(
original_resolution,
opts,
argument_value
) do
:not_found ->
:not_found

result ->
push_to_keys_path =
Keyword.get(opts, :new_name, argument_keys_path)
|> List.wrap()

MapHelpers.recursive_put_in(
popped_arguments,
push_to_keys_path,
result
)
end

case load_function.(input_value) do
nil ->
:error ->
arguments
end
end

defp load_entities(_original_resolution, arguments, _argument_name, _opts) do
arguments
end

defp call_load_function(original_resolution, opts, input_value) do
nil_is_not_found = Keyword.get(opts, :nil_is_not_found, true)
load_function = Keyword.fetch!(opts, :load_function)

case load_function.(original_resolution, input_value) do
nil when nil_is_not_found ->
:not_found

entities when is_list(entities) ->
# TODO: Can we assume the result is a list of entities
# based on the output of the load function?
# We could also check the input type,
# but the saver solution might be to to add a `is_list` option.
nil ->
nil

entities = Enum.reject(entities, &is_nil/1)
entities when is_list(entities) and is_list(input_value) ->
entities = if nil_is_not_found, do: Enum.reject(entities, &is_nil/1), else: entities

if is_list(input_value) and length(entities) != length(input_value) do
if length(entities) != length(input_value) do
:not_found
else
Map.put(arguments, push_to_key, entities)
entities
end

value ->
Map.put(arguments, push_to_key, value)
value
end
end

def load_entities(arguments, _argument_identifier, _opts) do
arguments
end
end
9 changes: 9 additions & 0 deletions test/interna/map_helpers_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
defmodule AbsintheUtils.Internal.MapHelpersTest do
@moduledoc false

use ExUnit.Case

alias AbsintheUtils.Internal.MapHelpers

doctest AbsintheUtils.Internal.MapHelpers
end
Loading