From e36dca41436591139767fef9b4aac9352eabf5fc Mon Sep 17 00:00:00 2001 From: Francisco Marques Date: Mon, 5 May 2025 10:29:14 +0100 Subject: [PATCH 1/5] arg loader tests and doc improvements - functionality preserved --- lib/middleware/arg_loader.ex | 30 +- test/middleware/arg_loader_test.exs | 1421 ++++++++++++++++++++++----- 2 files changed, 1166 insertions(+), 285 deletions(-) diff --git a/lib/middleware/arg_loader.ex b/lib/middleware/arg_loader.ex index e1a5234..2bec9ab 100644 --- a/lib/middleware/arg_loader.ex +++ b/lib/middleware/arg_loader.ex @@ -7,11 +7,12 @@ defmodule AbsintheUtils.Middleware.ArgLoader do As configuration it accepts a map of original argument names to a keyword list, containing: - - `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). + (optional, defaults to `argument_name`). + - `load_function`: the function used to load the argument into an entity. + As an input accepts one single argument: the input 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 . ## Examples @@ -68,7 +69,7 @@ defmodule AbsintheUtils.Middleware.ArgLoader do end ``` - Note the use of ` AbsintheUtils.Helpers.Sorting.sort_alike/2` to ensure the returned list of + 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. """ @@ -85,11 +86,11 @@ 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( arguments, argument_name, - opts + argument_opts ) do :not_found -> { @@ -125,22 +126,21 @@ defmodule AbsintheUtils.Middleware.ArgLoader do 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) + nil_is_not_found = Keyword.get(opts, :nil_is_not_found, true) {input_value, arguments} = Map.pop!(arguments, argument_name) case load_function.(input_value) do - nil -> + 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 -> + Map.put(arguments, push_to_key, 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) diff --git a/test/middleware/arg_loader_test.exs b/test/middleware/arg_loader_test.exs index f3b5c97..947e812 100644 --- a/test/middleware/arg_loader_test.exs +++ b/test/middleware/arg_loader_test.exs @@ -1,48 +1,123 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do use ExUnit.Case, async: true - alias AbsintheUtils.Middleware.ArgLoader + # use Absinthe.Schema defmodule SampleRepository do @moduledoc """ - This is a demo api used as a "mock" called by resolvers. - This test case would probably be better tested with real mocks on the resolvers + This is a demo api used as a "mock" called by resolve_paramss. + This test case would probably be better tested with real mocks on the resolve_paramss so we could assert calls and have more control of the flow. But for the ease of development this was chosen for now. """ - @users [ - %{id: "1", name: "Ally"}, - %{id: "2", name: "Bob"} - ] + @user_id_to_user %{ + "1" => %{id: "1", name: "Ally"}, + "2" => %{id: "2", name: "Bob"} + } - def get_by_id(enumerable, id) do - Enum.find( - enumerable, - fn item -> item.id == id end - ) + def get_user(id) do + Map.get(@user_id_to_user, id) end - def filter_by_ids(enumerable, ids) do - Enum.filter( - enumerable, - fn item -> item.id in ids end - ) + @doc """ + Demo multi-user getter that returns an user for each id provided. + If not found, `nil` is returned in its place. + """ + def get_optional_users(nil), do: [] + + def get_optional_users(ids) do + ids + |> Enum.map(&get_user/1) + end + + @doc """ + Demo multi-user getter that returns an user for each id provided. + If not found, it will not be returned. + """ + def get_users(ids) do + ids + |> get_optional_users() + |> Enum.reject(&is_nil/1) end - def get_user(id), do: get_by_id(@users, id) - def get_users(ids), do: filter_by_ids(@users, ids) + @doc """ + Demo multi-user getter that returns a list of unique users (removes duplicates from the input). + This is usually the case when loading users from database. + """ + def get_unique_users(nil), do: [] + + def get_unique_users(ids) do + ids + |> Enum.uniq() + |> get_users() + end end defmodule TestSchema do + @moduledoc false + use Absinthe.Schema + alias AbsintheUtils.Middleware.ArgLoader + object :user do - field(:id, :id) - field(:name, :string) + field(:id, non_null(:id)) + field(:name, non_null(:string)) + end + + object :single_entity do + field(:user, non_null(:user)) + end + + object :two_entities do + field(:user1, non_null(:user)) + field(:user2, non_null(:user)) + end + + object :entity_list do + field(:users, non_null(list_of(:user))) + end + + def resolve_params(_, params, _) do + {:ok, params} end query do - field :user, :user do + field :optional_entity, :single_entity do + arg(:id, :id) + + middleware( + ArgLoader, + %{ + id: [ + new_name: :user, + load_function: &SampleRepository.get_user/1, + nil_is_not_found: false + ] + } + ) + + resolve(&resolve_params/3) + end + + field :required_entity, :single_entity do + arg(:id, :id) + + middleware( + ArgLoader, + %{ + id: [ + new_name: :user, + load_function: &SampleRepository.get_user/1, + nil_is_not_found: true + ] + } + ) + + resolve(&resolve_params/3) + end + + field :required_entity_with_default, :single_entity do arg(:id, :id) middleware( @@ -51,110 +126,243 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do id: [ new_name: :user, load_function: &SampleRepository.get_user/1 + # Using default nil_is_not_found ] } ) - resolve(fn _, params, _ -> - {:ok, Map.get(params, :user)} - end) + resolve(&resolve_params/3) end - field :users, non_null(list_of(:user)) do - arg(:ids, non_null(list_of(:id))) + field :optional_entities, :entity_list do + arg(:ids, list_of(:id)) middleware( ArgLoader, %{ ids: [ new_name: :users, - load_function: &SampleRepository.get_users/1 + load_function: &SampleRepository.get_optional_users/1, + nil_is_not_found: false ] } ) - resolve(fn _, params, _ -> - {:ok, Map.get(params, :users)} - end) + resolve(&resolve_params/3) end - field :users_order_preserved, non_null(list_of(:user)) do - arg(:ids, non_null(list_of(:id))) + field :required_entities, :entity_list do + arg(:ids, list_of(:id)) middleware( ArgLoader, %{ ids: [ new_name: :users, - load_function: fn ids -> - ids - |> SampleRepository.get_users() - |> AbsintheUtils.Helpers.Sorting.sort_alike(ids, & &1.id) - end + load_function: &SampleRepository.get_users/1, + nil_is_not_found: true ] } ) - resolve(fn _, params, _ -> - {:ok, Map.get(params, :users)} - end) + resolve(&resolve_params/3) end - field :two_users, non_null(list_of(:user)) do - arg(:user1_id, :id) - arg(:user2_id, :id) + field :required_entities_with_default, :entity_list do + arg(:ids, list_of(:id)) middleware( ArgLoader, %{ - user1_id: [ - new_name: :user1, - load_function: &SampleRepository.get_user/1 - ], - user2_id: [ - new_name: :user2, - load_function: &SampleRepository.get_user/1 + ids: [ + new_name: :users, + load_function: &SampleRepository.get_users/1 + # Using default nil_is_not_found ] } ) - resolve(fn _, params, _ -> - { - :ok, - [ - Map.get(params, :user1), - Map.get(params, :user2) + resolve(&resolve_params/3) + end + + field :unique_entities, :entity_list do + arg(:ids, list_of(:id)) + + middleware( + ArgLoader, + %{ + ids: [ + new_name: :users, + load_function: &SampleRepository.get_unique_users/1 ] } - end) + ) + + resolve(&resolve_params/3) end + + # field :users, non_null(list_of(:user)) do + # arg(:ids, non_null(list_of(:id))) + + # middleware( + # ArgLoader, + # %{ + # ids: [ + # new_name: :users, + # load_function: &SampleRepository.get_users/1 + # ] + # } + # ) + + # resolve(&resolve_params/3) + # end + + # field :unique_users_order_preserved, non_null(list_of(:user)) do + # arg(:ids, non_null(list_of(:id))) + + # middleware( + # ArgLoader, + # %{ + # ids: [ + # new_name: :users, + # load_function: fn ids -> + # ids + # |> SampleRepository.get_users() + # |> AbsintheUtils.Helpers.Sorting.sort_alike(ids, & &1.id) + # end + # ] + # } + # ) + + # resolve(&resolve_params/3) + # end + + # field :two_entities, non_null(list_of(:user)) do + # arg(:user1_id, :id) + # arg(:user2_id, :id) + + # middleware( + # ArgLoader, + # %{ + # user1_id: [ + # new_name: :user1, + # load_function: &SampleRepository.get_user/1 + # ], + # user2_id: [ + # new_name: :user2, + # load_function: &SampleRepository.get_user/1 + # ] + # } + # ) + + # resolve(&resolve_params/3) + # end + end + end + + describe "optional entity" do + @query """ + query ($id: ID) { + optionalEntity( + id: $id + ) { + user { + id + name + } + } + } + """ + + test "success" do + assert {:ok, + %{ + data: %{ + "optionalEntity" => %{ + "user" => %{ + "id" => "1", + "name" => "Ally" + } + } + } + }} === + Absinthe.run( + @query, + TestSchema, + variables: %{ + "id" => "1" + } + ) + end + + test "nil argument" do + assert {:ok, + %{ + data: %{"optionalEntity" => nil} + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "id" => nil + } + ) + end + + test "argument not passed" do + assert {:ok, + %{ + data: %{"optionalEntity" => nil} + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{} + ) + end + + test "not found" do + assert {:ok, + %{ + data: %{"optionalEntity" => nil} + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "id" => "unknown" + } + ) end end - describe "loading one argument" do + describe "required entity" do + @query """ + query ($id: ID) { + requiredEntity( + id: $id + ) { + user { + id + name + } + } + } + """ test "success" do assert {:ok, %{ data: %{ - "user" => %{ - "id" => "1", - "name" => "Ally" + "requiredEntity" => %{ + "user" => %{ + "id" => "1", + "name" => "Ally" + } } } }} === Absinthe.run( - """ - query ( - $id: ID! - ) { - user( - id: $id - ){ - id - name - } - } - """, + @query, TestSchema, variables: %{ "id" => "1" @@ -162,23 +370,111 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do ) end - test "optional not passed" do + test "nil argument" do + assert {:ok, + %{ + errors: [ + %{ + extensions: %{code: "NOT_FOUND"}, + message: + "The entity(ies) provided in the following arg(s), could not be found: id" + } + ] + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "id" => nil + } + ) + end + + test "argument not passed" do + assert {:ok, + %{ + data: %{"requiredEntity" => nil} + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{} + ) + end + + test "not found" do + assert {:ok, + %{ + errors: [ + %{ + extensions: %{code: "NOT_FOUND"}, + message: + "The entity(ies) provided in the following arg(s), could not be found: id" + } + ] + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "id" => "unknown" + } + ) + end + end + + describe "required entity - using default" do + @query """ + query ($id: ID) { + requiredEntityWithDefault( + id: $id + ) { + user { + id + name + } + } + } + """ + + test "success" do assert {:ok, %{ data: %{ - "user" => nil + "requiredEntityWithDefault" => %{ + "user" => %{ + "id" => "1", + "name" => "Ally" + } + } } }} === Absinthe.run( - """ - query { - user { - id - name - } - } - """, - TestSchema + @query, + TestSchema, + variables: %{ + "id" => "1" + } + ) + end + + test "nil argument" do + assert {:ok, + %{ + errors: [ + %{ + extensions: %{code: "NOT_FOUND"}, + message: + "The entity(ies) provided in the following arg(s), could not be found: id" + } + ] + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "id" => nil + } ) end @@ -194,82 +490,202 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do ] }} = Absinthe.run( - """ - query ( - $id: ID! - ) { - user( - id: $id - ){ - id - name - } - } - """, + @query, + TestSchema, + variables: %{ + "id" => "unknown" + } + ) + end + end + + describe "optional entities" do + @query """ + query ($ids: [ID]) { + optionalEntities( + ids: $ids + ) { + users { + id + name + } + } + } + """ + + test "success" do + assert {:ok, + %{ + data: %{ + "optionalEntities" => %{ + "users" => [ + %{ + "id" => "1", + "name" => "Ally" + } + ] + } + } + }} === + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => ["1"] + } + ) + end + + test "nil input list" do + assert {:ok, + %{ + data: %{"optionalEntities" => %{"users" => []}} + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => nil + } + ) + end + + test "argument not passed" do + assert {:ok, + %{ + data: %{"optionalEntities" => nil} + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{} + ) + end + + test "nil element id" do + assert {:ok, + %{ + data: %{"optionalEntities" => %{"users" => [nil]}} + }} = + Absinthe.run( + @query, TestSchema, variables: %{ - "id" => "invalid" + "ids" => [nil] + } + ) + end + + test "not found" do + assert {:ok, + %{ + data: %{"optionalEntities" => %{"users" => [nil]}} + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => ["unknown"] + } + ) + end + + test "mixed- valid and not found" do + assert {:ok, + %{ + data: %{ + "optionalEntities" => %{"users" => [nil, %{"id" => "1", "name" => "Ally"}]} + } + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => ["unknown", "1"] } ) end end - describe "loading one array argument" do + describe "required entities" do + @query """ + query ($ids: [ID]) { + requiredEntities( + ids: $ids + ) { + users { + id + name + } + } + } + """ + test "success" do assert {:ok, %{ data: %{ - "users" => [ - %{"id" => "1", "name" => "Ally"}, - %{"id" => "2", "name" => "Bob"} - ] + "requiredEntities" => %{ + "users" => [ + %{ + "id" => "1", + "name" => "Ally" + } + ] + } } }} === Absinthe.run( - """ - query ( - $ids: [ID]! - ) { - users( - ids: $ids - ){ - id - name - } - } - """, + @query, TestSchema, variables: %{ - "ids" => ["1", "2"] + "ids" => ["1"] } ) end - test "empty list" do - assert { - :ok, - %{ - data: %{ - "users" => [] + test "nil input list" do + assert {:ok, + %{ + data: %{"requiredEntities" => %{"users" => []}} + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => nil } - } - } === - Absinthe.run( - """ - query ( - $ids: [ID]! - ) { - users( - ids: $ids - ){ - id - name - } - } - """, + ) + end + + test "argument not passed" do + assert {:ok, + %{ + data: %{"requiredEntities" => nil} + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{} + ) + end + + test "nil element id" do + assert {:ok, + %{ + errors: [ + %{ + extensions: %{code: "NOT_FOUND"}, + message: + "The entity(ies) provided in the following arg(s), could not be found: ids" + } + ] + }} = + Absinthe.run( + @query, TestSchema, variables: %{ - "ids" => [] + "ids" => [nil] } ) end @@ -286,26 +702,15 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do ] }} = Absinthe.run( - """ - query ( - $ids: [ID]! - ) { - users( - ids: $ids - ){ - id - name - } - } - """, + @query, TestSchema, variables: %{ - "ids" => ["1", "invalid", "2"] + "ids" => ["unknown"] } ) end - test "not found when using Sorting.sort_alike" do + test "mixed- valid and not found" do assert {:ok, %{ errors: [ @@ -317,151 +722,627 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do ] }} = Absinthe.run( - """ - query ( - $ids: [ID]! - ) { - usersOrderPreserved( - ids: $ids - ){ - id - name - } - } - """, + @query, TestSchema, variables: %{ - "ids" => ["1", "invalid", "2"] + "ids" => ["unknown", "1"] } ) end end - describe "loading two arguments" do + describe "required entities - using default" do + @query """ + query ($ids: [ID]) { + requiredEntitiesWithDefault( + ids: $ids + ) { + users { + id + name + } + } + } + """ + test "success" do - assert { - :ok, - %{ - data: %{ - "twoUsers" => [ - %{"id" => "2", "name" => "Bob"}, - %{"id" => "1", "name" => "Ally"} - ] + assert {:ok, + %{ + data: %{ + "requiredEntitiesWithDefault" => %{ + "users" => [ + %{ + "id" => "1", + "name" => "Ally" + } + ] + } + } + }} === + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => ["1"] + } + ) + end + + test "nil input list" do + assert {:ok, + %{ + data: %{"requiredEntitiesWithDefault" => %{"users" => []}} + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => nil + } + ) + end + + test "argument not passed" do + assert {:ok, + %{ + data: %{"requiredEntitiesWithDefault" => nil} + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{} + ) + end + + test "nil element id" do + assert {:ok, + %{ + errors: [ + %{ + extensions: %{code: "NOT_FOUND"}, + message: + "The entity(ies) provided in the following arg(s), could not be found: ids" + } + ] + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => [nil] + } + ) + end + + test "not found" do + assert {:ok, + %{ + errors: [ + %{ + extensions: %{code: "NOT_FOUND"}, + message: + "The entity(ies) provided in the following arg(s), could not be found: ids" + } + ] + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => ["unknown"] } - } - } === - Absinthe.run( - """ - query ( - $user1Id: ID! - $user2Id: ID! - ) { - twoUsers ( - user1Id: $user1Id - user2Id: $user2Id - ) { - id - name - } - } - """, + ) + end + + test "mixed - valid and not found" do + assert {:ok, + %{ + errors: [ + %{ + extensions: %{code: "NOT_FOUND"}, + message: + "The entity(ies) provided in the following arg(s), could not be found: ids" + } + ] + }} = + Absinthe.run( + @query, TestSchema, variables: %{ - "user1Id" => "2", - "user2Id" => "1" + "ids" => ["unknown", "1"] } ) end + end - test "one not provided" do - assert { - :ok, - %{ - data: %{ - "twoUsers" => [ - nil, - %{"id" => "2", "name" => "Bob"} - ] + describe "unique entities" do + @query """ + query ($ids: [ID]) { + uniqueEntities( + ids: $ids + ) { + users { + id + name + } + } + } + """ + + test "success" do + assert {:ok, + %{ + data: %{ + "uniqueEntities" => %{ + "users" => [ + %{ + "id" => "1", + "name" => "Ally" + } + ] + } + } + }} === + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => ["1"] + } + ) + end + + test "nil input list" do + assert {:ok, + %{ + data: %{"uniqueEntities" => %{"users" => []}} + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => nil } - } - } === - Absinthe.run( - """ - query ( - $user2Id: ID! - ) { - twoUsers ( - user2Id: $user2Id - ) { - id - name - } - } - """, + ) + end + + test "argument not passed" do + assert {:ok, + %{ + data: %{"uniqueEntities" => nil} + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{} + ) + end + + test "nil element id" do + assert {:ok, + %{ + errors: [ + %{ + extensions: %{code: "NOT_FOUND"}, + message: + "The entity(ies) provided in the following arg(s), could not be found: ids" + } + ] + }} = + Absinthe.run( + @query, TestSchema, variables: %{ - "user2Id" => "2" + "ids" => [nil] } ) end - test "none provided" do - assert { - :ok, - %{ - data: %{ - "twoUsers" => [nil, nil] + test "not found" do + assert {:ok, + %{ + errors: [ + %{ + extensions: %{code: "NOT_FOUND"}, + message: + "The entity(ies) provided in the following arg(s), could not be found: ids" + } + ] + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => ["unknown"] } - } - } === - Absinthe.run( - """ - query { - twoUsers { - id - name - } - } - """, - TestSchema - ) - end - - test "error one not found" do - assert { - :ok, - %{ - data: nil, - errors: [ - %{ - extensions: %{code: "NOT_FOUND"}, - message: - "The entity(ies) provided in the following arg(s), could not be found: user2_id" - } - ] - } - } = - Absinthe.run( - """ - query ( - $user1Id: ID! - $user2Id: ID! - ) { - twoUsers ( - user1Id: $user1Id - user2Id: $user2Id - ) { - id - name - } - } - """, + ) + end + + test "mixed- valid and not found" do + assert {:ok, + %{ + errors: [ + %{ + extensions: %{code: "NOT_FOUND"}, + message: + "The entity(ies) provided in the following arg(s), could not be found: ids" + } + ] + }} = + Absinthe.run( + @query, TestSchema, variables: %{ - "user1Id" => "1", - "user2Id" => "invalid" + "ids" => ["unknown", "1"] } ) end end end + +# #### + +# describe "loading one argument" do +# test "success" do +# assert {:ok, +# %{ +# data: %{ +# "user" => %{ +# "id" => "1", +# "name" => "Ally" +# } +# } +# }} === +# Absinthe.run( +# """ +# query ( +# $id: ID! +# ) { +# user( +# id: $id +# ){ +# id +# name +# } +# } +# """, +# TestSchema, +# variables: %{ +# "id" => "1" +# } +# ) +# end + +# test "optional not passed" do +# assert {:ok, +# %{ +# data: %{ +# "user" => nil +# } +# }} === +# Absinthe.run( +# """ +# query { +# user { +# id +# name +# } +# } +# """, +# TestSchema +# ) +# end + +# test "not found" do +# assert {:ok, +# %{ +# errors: [ +# %{ +# extensions: %{code: "NOT_FOUND"}, +# message: +# "The entity(ies) provided in the following arg(s), could not be found: id" +# } +# ] +# }} = +# Absinthe.run( +# """ +# query ( +# $id: ID! +# ) { +# user( +# id: $id +# ){ +# id +# name +# } +# } +# """, +# TestSchema, +# variables: %{ +# "id" => "unknown" +# } +# ) +# end +# end + +# describe "loading one array argument" do +# test "success" do +# assert {:ok, +# %{ +# data: %{ +# "users" => [ +# %{"id" => "1", "name" => "Ally"}, +# %{"id" => "2", "name" => "Bob"} +# ] +# } +# }} === +# Absinthe.run( +# """ +# query ( +# $ids: [ID]! +# ) { +# users( +# ids: $ids +# ){ +# id +# name +# } +# } +# """, +# TestSchema, +# variables: %{ +# "ids" => ["1", "2"] +# } +# ) +# end + +# test "empty list" do +# assert { +# :ok, +# %{ +# data: %{ +# "users" => [] +# } +# } +# } === +# Absinthe.run( +# """ +# query ( +# $ids: [ID]! +# ) { +# users( +# ids: $ids +# ){ +# id +# name +# } +# } +# """, +# TestSchema, +# variables: %{ +# "ids" => [] +# } +# ) +# end + +# test "not found - unknown id" do +# assert {:ok, +# %{ +# errors: [ +# %{ +# extensions: %{code: "NOT_FOUND"}, +# message: +# "The entity(ies) provided in the following arg(s), could not be found: ids" +# } +# ] +# }} = +# Absinthe.run( +# """ +# query ( +# $ids: [ID]! +# ) { +# users( +# ids: $ids +# ){ +# id +# name +# } +# } +# """, +# TestSchema, +# variables: %{ +# "ids" => ["1", "unknown", "2"] +# } +# ) +# end + +# test "not found - duplicate ids" do +# assert {:ok, +# %{ +# errors: [ +# %{ +# extensions: %{code: "NOT_FOUND"}, +# message: +# "The entity(ies) provided in the following arg(s), could not be found: ids" +# } +# ] +# }} = +# Absinthe.run( +# """ +# query ( +# $ids: [ID]! +# ) { +# users( +# ids: $ids +# ){ +# id +# name +# } +# } +# """, +# TestSchema, +# variables: %{ +# "ids" => ["1", "1", "2"] +# } +# ) +# end + +# test "not found when using Sorting.sort_alike" do +# assert {:ok, +# %{ +# errors: [ +# %{ +# extensions: %{code: "NOT_FOUND"}, +# message: +# "The entity(ies) provided in the following arg(s), could not be found: ids" +# } +# ] +# }} = +# Absinthe.run( +# """ +# query ( +# $ids: [ID]! +# ) { +# usersOrderPreserved( +# ids: $ids +# ){ +# id +# name +# } +# } +# """, +# TestSchema, +# variables: %{ +# "ids" => ["1", "unknown", "2"] +# } +# ) +# end +# end + +# describe "loading two arguments" do +# test "success" do +# assert { +# :ok, +# %{ +# data: %{ +# "twoUsers" => [ +# %{"id" => "2", "name" => "Bob"}, +# %{"id" => "1", "name" => "Ally"} +# ] +# } +# } +# } === +# Absinthe.run( +# """ +# query ( +# $user1Id: ID! +# $user2Id: ID! +# ) { +# twoUsers ( +# user1Id: $user1Id +# user2Id: $user2Id +# ) { +# id +# name +# } +# } +# """, +# TestSchema, +# variables: %{ +# "user1Id" => "2", +# "user2Id" => "1" +# } +# ) +# end + +# test "one not provided" do +# assert { +# :ok, +# %{ +# data: %{ +# "twoUsers" => [ +# nil, +# %{"id" => "2", "name" => "Bob"} +# ] +# } +# } +# } === +# Absinthe.run( +# """ +# query ( +# $user2Id: ID! +# ) { +# twoUsers ( +# user2Id: $user2Id +# ) { +# id +# name +# } +# } +# """, +# TestSchema, +# variables: %{ +# "user2Id" => "2" +# } +# ) +# end + +# test "none provided" do +# assert { +# :ok, +# %{ +# data: %{ +# "twoUsers" => [nil, nil] +# } +# } +# } === +# Absinthe.run( +# """ +# query { +# twoUsers { +# id +# name +# } +# } +# """, +# TestSchema +# ) +# end + +# test "error one not found" do +# assert { +# :ok, +# %{ +# data: nil, +# errors: [ +# %{ +# extensions: %{code: "NOT_FOUND"}, +# message: +# "The entity(ies) provided in the following arg(s), could not be found: user2_id" +# } +# ] +# } +# } = +# Absinthe.run( +# """ +# query ( +# $user1Id: ID! +# $user2Id: ID! +# ) { +# twoUsers ( +# user1Id: $user1Id +# user2Id: $user2Id +# ) { +# id +# name +# } +# } +# """, +# TestSchema, +# variables: %{ +# "user1Id" => "1", +# "user2Id" => "unknown" +# } +# ) +# end +# end From 43678b674cafd306fb75834876deebfe57cbeec7 Mon Sep 17 00:00:00 2001 From: Francisco Marques Date: Mon, 5 May 2025 10:55:39 +0100 Subject: [PATCH 2/5] more tests and improve error messages --- lib/middleware/arg_loader.ex | 11 +- test/middleware/arg_loader_test.exs | 825 +++++++++++++--------------- 2 files changed, 403 insertions(+), 433 deletions(-) diff --git a/lib/middleware/arg_loader.ex b/lib/middleware/arg_loader.ex index 2bec9ab..0cff9d8 100644 --- a/lib/middleware/arg_loader.ex +++ b/lib/middleware/arg_loader.ex @@ -113,10 +113,17 @@ 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 diff --git a/test/middleware/arg_loader_test.exs b/test/middleware/arg_loader_test.exs index 947e812..1038167 100644 --- a/test/middleware/arg_loader_test.exs +++ b/test/middleware/arg_loader_test.exs @@ -5,8 +5,8 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do defmodule SampleRepository do @moduledoc """ - This is a demo api used as a "mock" called by resolve_paramss. - This test case would probably be better tested with real mocks on the resolve_paramss + This is a demo api used as a "mock" called by resolve_params. + This test case would probably be better tested with real mocks on the resolve_params so we could assert calls and have more control of the flow. But for the ease of development this was chosen for now. """ @@ -34,6 +34,8 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do Demo multi-user getter that returns an user for each id provided. If not found, it will not be returned. """ + def get_users(nil), do: [] + def get_users(ids) do ids |> get_optional_users() @@ -70,8 +72,8 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do end object :two_entities do - field(:user1, non_null(:user)) - field(:user2, non_null(:user)) + field(:user1, :user) + field(:user2, :user) end object :entity_list do @@ -193,7 +195,51 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do %{ ids: [ new_name: :users, - load_function: &SampleRepository.get_unique_users/1 + load_function: &SampleRepository.get_unique_users/1, + nil_is_not_found: false + ] + } + ) + + resolve(&resolve_params/3) + end + + field :required_entities_order_preserved, :entity_list do + arg(:ids, non_null(list_of(:id))) + + middleware( + ArgLoader, + %{ + ids: [ + new_name: :users, + load_function: fn ids -> + ids + |> SampleRepository.get_users() + |> AbsintheUtils.Helpers.Sorting.sort_alike(ids, & &1.id) + end + ] + } + ) + + resolve(&resolve_params/3) + end + + field :two_optional_entities, :two_entities do + arg(:user1_id, :id) + arg(:user2_id, :id) + + middleware( + ArgLoader, + %{ + user1_id: [ + new_name: :user1, + load_function: &SampleRepository.get_user/1, + nil_is_not_found: false + ], + user2_id: [ + new_name: :user2, + load_function: &SampleRepository.get_user/1, + nil_is_not_found: false ] } ) @@ -201,62 +247,28 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do resolve(&resolve_params/3) end - # field :users, non_null(list_of(:user)) do - # arg(:ids, non_null(list_of(:id))) - - # middleware( - # ArgLoader, - # %{ - # ids: [ - # new_name: :users, - # load_function: &SampleRepository.get_users/1 - # ] - # } - # ) - - # resolve(&resolve_params/3) - # end - - # field :unique_users_order_preserved, non_null(list_of(:user)) do - # arg(:ids, non_null(list_of(:id))) - - # middleware( - # ArgLoader, - # %{ - # ids: [ - # new_name: :users, - # load_function: fn ids -> - # ids - # |> SampleRepository.get_users() - # |> AbsintheUtils.Helpers.Sorting.sort_alike(ids, & &1.id) - # end - # ] - # } - # ) - - # resolve(&resolve_params/3) - # end - - # field :two_entities, non_null(list_of(:user)) do - # arg(:user1_id, :id) - # arg(:user2_id, :id) - - # middleware( - # ArgLoader, - # %{ - # user1_id: [ - # new_name: :user1, - # load_function: &SampleRepository.get_user/1 - # ], - # user2_id: [ - # new_name: :user2, - # load_function: &SampleRepository.get_user/1 - # ] - # } - # ) - - # resolve(&resolve_params/3) - # end + field :two_required_entities, :two_entities do + arg(:user1_id, :id) + arg(:user2_id, :id) + + middleware( + ArgLoader, + %{ + user1_id: [ + new_name: :user1, + load_function: &SampleRepository.get_user/1, + nil_is_not_found: true + ], + user2_id: [ + new_name: :user2, + load_function: &SampleRepository.get_user/1, + nil_is_not_found: true + ] + } + ) + + resolve(&resolve_params/3) + end end end @@ -590,7 +602,7 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do ) end - test "mixed- valid and not found" do + test "mixed - valid and not found" do assert {:ok, %{ data: %{ @@ -710,7 +722,7 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do ) end - test "mixed- valid and not found" do + test "mixed - valid and not found" do assert {:ok, %{ errors: [ @@ -892,6 +904,26 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do ) end + test "success - duplicate ids" do + assert {:ok, + %{ + errors: [ + %{ + message: + "The entity(ies) provided in the following arg(s), could not be found: ids", + extensions: %{code: "NOT_FOUND"} + } + ] + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => ["1", "1"] + } + ) + end + test "nil input list" do assert {:ok, %{ @@ -958,7 +990,7 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do ) end - test "mixed- valid and not found" do + test "mixed - valid and not found" do assert {:ok, %{ errors: [ @@ -978,371 +1010,302 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do ) end end -end -# #### - -# describe "loading one argument" do -# test "success" do -# assert {:ok, -# %{ -# data: %{ -# "user" => %{ -# "id" => "1", -# "name" => "Ally" -# } -# } -# }} === -# Absinthe.run( -# """ -# query ( -# $id: ID! -# ) { -# user( -# id: $id -# ){ -# id -# name -# } -# } -# """, -# TestSchema, -# variables: %{ -# "id" => "1" -# } -# ) -# end - -# test "optional not passed" do -# assert {:ok, -# %{ -# data: %{ -# "user" => nil -# } -# }} === -# Absinthe.run( -# """ -# query { -# user { -# id -# name -# } -# } -# """, -# TestSchema -# ) -# end - -# test "not found" do -# assert {:ok, -# %{ -# errors: [ -# %{ -# extensions: %{code: "NOT_FOUND"}, -# message: -# "The entity(ies) provided in the following arg(s), could not be found: id" -# } -# ] -# }} = -# Absinthe.run( -# """ -# query ( -# $id: ID! -# ) { -# user( -# id: $id -# ){ -# id -# name -# } -# } -# """, -# TestSchema, -# variables: %{ -# "id" => "unknown" -# } -# ) -# end -# end - -# describe "loading one array argument" do -# test "success" do -# assert {:ok, -# %{ -# data: %{ -# "users" => [ -# %{"id" => "1", "name" => "Ally"}, -# %{"id" => "2", "name" => "Bob"} -# ] -# } -# }} === -# Absinthe.run( -# """ -# query ( -# $ids: [ID]! -# ) { -# users( -# ids: $ids -# ){ -# id -# name -# } -# } -# """, -# TestSchema, -# variables: %{ -# "ids" => ["1", "2"] -# } -# ) -# end - -# test "empty list" do -# assert { -# :ok, -# %{ -# data: %{ -# "users" => [] -# } -# } -# } === -# Absinthe.run( -# """ -# query ( -# $ids: [ID]! -# ) { -# users( -# ids: $ids -# ){ -# id -# name -# } -# } -# """, -# TestSchema, -# variables: %{ -# "ids" => [] -# } -# ) -# end - -# test "not found - unknown id" do -# assert {:ok, -# %{ -# errors: [ -# %{ -# extensions: %{code: "NOT_FOUND"}, -# message: -# "The entity(ies) provided in the following arg(s), could not be found: ids" -# } -# ] -# }} = -# Absinthe.run( -# """ -# query ( -# $ids: [ID]! -# ) { -# users( -# ids: $ids -# ){ -# id -# name -# } -# } -# """, -# TestSchema, -# variables: %{ -# "ids" => ["1", "unknown", "2"] -# } -# ) -# end - -# test "not found - duplicate ids" do -# assert {:ok, -# %{ -# errors: [ -# %{ -# extensions: %{code: "NOT_FOUND"}, -# message: -# "The entity(ies) provided in the following arg(s), could not be found: ids" -# } -# ] -# }} = -# Absinthe.run( -# """ -# query ( -# $ids: [ID]! -# ) { -# users( -# ids: $ids -# ){ -# id -# name -# } -# } -# """, -# TestSchema, -# variables: %{ -# "ids" => ["1", "1", "2"] -# } -# ) -# end - -# test "not found when using Sorting.sort_alike" do -# assert {:ok, -# %{ -# errors: [ -# %{ -# extensions: %{code: "NOT_FOUND"}, -# message: -# "The entity(ies) provided in the following arg(s), could not be found: ids" -# } -# ] -# }} = -# Absinthe.run( -# """ -# query ( -# $ids: [ID]! -# ) { -# usersOrderPreserved( -# ids: $ids -# ){ -# id -# name -# } -# } -# """, -# TestSchema, -# variables: %{ -# "ids" => ["1", "unknown", "2"] -# } -# ) -# end -# end - -# describe "loading two arguments" do -# test "success" do -# assert { -# :ok, -# %{ -# data: %{ -# "twoUsers" => [ -# %{"id" => "2", "name" => "Bob"}, -# %{"id" => "1", "name" => "Ally"} -# ] -# } -# } -# } === -# Absinthe.run( -# """ -# query ( -# $user1Id: ID! -# $user2Id: ID! -# ) { -# twoUsers ( -# user1Id: $user1Id -# user2Id: $user2Id -# ) { -# id -# name -# } -# } -# """, -# TestSchema, -# variables: %{ -# "user1Id" => "2", -# "user2Id" => "1" -# } -# ) -# end - -# test "one not provided" do -# assert { -# :ok, -# %{ -# data: %{ -# "twoUsers" => [ -# nil, -# %{"id" => "2", "name" => "Bob"} -# ] -# } -# } -# } === -# Absinthe.run( -# """ -# query ( -# $user2Id: ID! -# ) { -# twoUsers ( -# user2Id: $user2Id -# ) { -# id -# name -# } -# } -# """, -# TestSchema, -# variables: %{ -# "user2Id" => "2" -# } -# ) -# end - -# test "none provided" do -# assert { -# :ok, -# %{ -# data: %{ -# "twoUsers" => [nil, nil] -# } -# } -# } === -# Absinthe.run( -# """ -# query { -# twoUsers { -# id -# name -# } -# } -# """, -# TestSchema -# ) -# end - -# test "error one not found" do -# assert { -# :ok, -# %{ -# data: nil, -# errors: [ -# %{ -# extensions: %{code: "NOT_FOUND"}, -# message: -# "The entity(ies) provided in the following arg(s), could not be found: user2_id" -# } -# ] -# } -# } = -# Absinthe.run( -# """ -# query ( -# $user1Id: ID! -# $user2Id: ID! -# ) { -# twoUsers ( -# user1Id: $user1Id -# user2Id: $user2Id -# ) { -# id -# name -# } -# } -# """, -# TestSchema, -# variables: %{ -# "user1Id" => "1", -# "user2Id" => "unknown" -# } -# ) -# end -# end + describe "required entities - order preserved" do + @query """ + query ($ids: [ID]!) { + requiredEntitiesOrderPreserved( + ids: $ids + ) { + users { + id + name + } + } + } + """ + + test "success" do + assert {:ok, + %{ + data: %{ + "requiredEntitiesOrderPreserved" => %{ + "users" => [ + %{"id" => "2", "name" => "Bob"}, + %{"id" => "1", "name" => "Ally"} + ] + } + } + }} === + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => ["2", "1"] + } + ) + end + + test "success - duplicate ids" do + assert {:ok, + %{ + data: %{ + "requiredEntitiesOrderPreserved" => %{ + "users" => [ + %{"id" => "1", "name" => "Ally"}, + %{"id" => "2", "name" => "Bob"}, + %{"id" => "1", "name" => "Ally"} + ] + } + } + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => ["1", "2", "1"] + } + ) + end + + test "nil element id" do + assert {:ok, + %{ + errors: [ + %{ + extensions: %{code: "NOT_FOUND"}, + message: + "The entity(ies) provided in the following arg(s), could not be found: ids" + } + ] + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => [nil] + } + ) + end + + test "not found" do + assert {:ok, + %{ + errors: [ + %{ + extensions: %{code: "NOT_FOUND"}, + message: + "The entity(ies) provided in the following arg(s), could not be found: ids" + } + ] + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => ["unknown"] + } + ) + end + + test "mixed - valid and not found" do + assert {:ok, + %{ + errors: [ + %{ + extensions: %{code: "NOT_FOUND"}, + message: + "The entity(ies) provided in the following arg(s), could not be found: ids" + } + ] + }} = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "ids" => ["unknown", "1"] + } + ) + end + end + + describe "two optional entities" do + @query """ + query ( + $user1Id: ID + $user2Id: ID + ) { + twoOptionalEntities ( + user1Id: $user1Id + user2Id: $user2Id + ) { + user1 { + id + name + } + user2 { + id + name + } + } + } + """ + + test "success" do + assert { + :ok, + %{ + data: %{ + "twoOptionalEntities" => %{ + "user1" => %{"id" => "2", "name" => "Bob"}, + "user2" => %{"id" => "1", "name" => "Ally"} + } + } + } + } === + Absinthe.run( + @query, + TestSchema, + variables: %{ + "user1Id" => "2", + "user2Id" => "1" + } + ) + end + + test "success - one not found" do + assert { + :ok, + %{ + data: %{ + "twoOptionalEntities" => %{ + "user1" => nil, + "user2" => %{"id" => "1", "name" => "Ally"} + } + } + } + } = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "user1Id" => nil, + "user2Id" => "1" + } + ) + end + + test "success - both not found" do + assert { + :ok, + %{ + data: %{ + "twoOptionalEntities" => %{ + "user1" => nil, + "user2" => nil + } + } + } + } = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "user1Id" => "unknown", + "user2Id" => "unknown" + } + ) + end + end + + describe "two required entities" do + @query """ + query ( + $user1Id: ID + $user2Id: ID + ) { + twoRequiredEntities ( + user1Id: $user1Id + user2Id: $user2Id + ) { + user1 { + id + name + } + user2 { + id + name + } + } + } + """ + + test "success" do + assert { + :ok, + %{ + data: %{ + "twoRequiredEntities" => %{ + "user1" => %{"id" => "2", "name" => "Bob"}, + "user2" => %{"id" => "1", "name" => "Ally"} + } + } + } + } === + Absinthe.run( + @query, + TestSchema, + variables: %{ + "user1Id" => "2", + "user2Id" => "1" + } + ) + end + + test "success - one not found" do + assert { + :ok, + %{ + errors: [ + %{ + extensions: %{code: "NOT_FOUND"}, + message: + "The entity(ies) provided in the following arg(s), could not be found: user1Id" + } + ] + } + } = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "user1Id" => "unknown", + "user2Id" => "1" + } + ) + end + + test "success - both not found" do + assert { + :ok, + %{ + errors: [ + %{ + extensions: %{code: "NOT_FOUND"}, + message: + "The entity(ies) provided in the following arg(s), could not be found: user2Id, user1Id" + } + ] + } + } = + Absinthe.run( + @query, + TestSchema, + variables: %{ + "user1Id" => "unknown", + "user2Id" => "unknown" + } + ) + end + end +end From 7cfe4400e3144e747ef715e8ed552ff0912f12a6 Mon Sep 17 00:00:00 2001 From: Francisco Marques Date: Mon, 5 May 2025 11:48:05 +0100 Subject: [PATCH 3/5] add context to arg loader --- lib/middleware/arg_loader.ex | 21 ++++++++----- test/middleware/arg_loader_test.exs | 46 +++++++++++++++++++++-------- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/lib/middleware/arg_loader.ex b/lib/middleware/arg_loader.ex index 0cff9d8..93ea91f 100644 --- a/lib/middleware/arg_loader.ex +++ b/lib/middleware/arg_loader.ex @@ -10,9 +10,13 @@ defmodule AbsintheUtils.Middleware.ArgLoader do - `new_name`: the new name to push the loaded entity into. (optional, defaults to `argument_name`). - `load_function`: the function used to load the argument into an entity. - As an input accepts one single argument: the input received in the value of `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 @@ -27,7 +31,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 ] } ) @@ -39,7 +46,6 @@ 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`. `ArgLoader` can also be used to load a `list_of` arguments: @@ -53,7 +59,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) @@ -88,6 +94,7 @@ defmodule AbsintheUtils.Middleware.ArgLoader do {arguments, []}, fn {argument_name, argument_opts}, {arguments, not_found_arguments} -> case load_entities( + resolution, arguments, argument_name, argument_opts @@ -129,7 +136,7 @@ defmodule AbsintheUtils.Middleware.ArgLoader do end end - def load_entities(arguments, argument_name, opts) + def load_entities(original_resolution, 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) @@ -137,7 +144,7 @@ defmodule AbsintheUtils.Middleware.ArgLoader do {input_value, arguments} = Map.pop!(arguments, argument_name) - case load_function.(input_value) do + case load_function.(original_resolution, input_value) do nil when nil_is_not_found -> :not_found @@ -158,7 +165,7 @@ defmodule AbsintheUtils.Middleware.ArgLoader do end end - def load_entities(arguments, _argument_identifier, _opts) do + def load_entities(_original_resolution, arguments, _argument_name, _opts) do arguments end end diff --git a/test/middleware/arg_loader_test.exs b/test/middleware/arg_loader_test.exs index 1038167..596cb32 100644 --- a/test/middleware/arg_loader_test.exs +++ b/test/middleware/arg_loader_test.exs @@ -93,7 +93,9 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do %{ id: [ new_name: :user, - load_function: &SampleRepository.get_user/1, + load_function: fn _context, input_value -> + SampleRepository.get_user(input_value) + end, nil_is_not_found: false ] } @@ -110,7 +112,9 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do %{ id: [ new_name: :user, - load_function: &SampleRepository.get_user/1, + load_function: fn _context, input_value -> + SampleRepository.get_user(input_value) + end, nil_is_not_found: true ] } @@ -127,7 +131,9 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do %{ id: [ new_name: :user, - load_function: &SampleRepository.get_user/1 + load_function: fn _context, input_value -> + SampleRepository.get_user(input_value) + end # Using default nil_is_not_found ] } @@ -144,7 +150,9 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do %{ ids: [ new_name: :users, - load_function: &SampleRepository.get_optional_users/1, + load_function: fn _context, input_value -> + SampleRepository.get_optional_users(input_value) + end, nil_is_not_found: false ] } @@ -161,7 +169,9 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do %{ ids: [ new_name: :users, - load_function: &SampleRepository.get_users/1, + load_function: fn _context, input_value -> + SampleRepository.get_users(input_value) + end, nil_is_not_found: true ] } @@ -178,7 +188,9 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do %{ ids: [ new_name: :users, - load_function: &SampleRepository.get_users/1 + load_function: fn _context, input_value -> + SampleRepository.get_users(input_value) + end # Using default nil_is_not_found ] } @@ -195,7 +207,9 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do %{ ids: [ new_name: :users, - load_function: &SampleRepository.get_unique_users/1, + load_function: fn _context, input_value -> + SampleRepository.get_unique_users(input_value) + end, nil_is_not_found: false ] } @@ -212,7 +226,7 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do %{ ids: [ new_name: :users, - load_function: fn ids -> + load_function: fn _context, ids -> ids |> SampleRepository.get_users() |> AbsintheUtils.Helpers.Sorting.sort_alike(ids, & &1.id) @@ -233,12 +247,16 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do %{ user1_id: [ new_name: :user1, - load_function: &SampleRepository.get_user/1, + load_function: fn _context, input_value -> + SampleRepository.get_user(input_value) + end, nil_is_not_found: false ], user2_id: [ new_name: :user2, - load_function: &SampleRepository.get_user/1, + load_function: fn _context, input_value -> + SampleRepository.get_user(input_value) + end, nil_is_not_found: false ] } @@ -256,12 +274,16 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do %{ user1_id: [ new_name: :user1, - load_function: &SampleRepository.get_user/1, + load_function: fn _context, input_value -> + SampleRepository.get_user(input_value) + end, nil_is_not_found: true ], user2_id: [ new_name: :user2, - load_function: &SampleRepository.get_user/1, + load_function: fn _context, input_value -> + SampleRepository.get_user(input_value) + end, nil_is_not_found: true ] } From cbb61ca1568812a08cba2cf12a6fde340720b990 Mon Sep 17 00:00:00 2001 From: Francisco Marques Date: Mon, 5 May 2025 14:44:04 +0100 Subject: [PATCH 4/5] implement deep getter and setter on arg loader --- lib/internal/map_helpers.ex | 106 ++++++++++++++++++++++++++++ lib/middleware/arg_loader.ex | 64 ++++++++++++----- test/interna/map_helpers_test.exs | 9 +++ test/middleware/arg_loader_test.exs | 80 +++++++++++++++++++++ 4 files changed, 243 insertions(+), 16 deletions(-) create mode 100644 lib/internal/map_helpers.ex create mode 100644 test/interna/map_helpers_test.exs diff --git a/lib/internal/map_helpers.ex b/lib/internal/map_helpers.ex new file mode 100644 index 0000000..f563578 --- /dev/null +++ b/lib/internal/map_helpers.ex @@ -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 diff --git a/lib/middleware/arg_loader.ex b/lib/middleware/arg_loader.ex index 93ea91f..07d4003 100644 --- a/lib/middleware/arg_loader.ex +++ b/lib/middleware/arg_loader.ex @@ -5,10 +5,11 @@ 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 name to push the loaded entity into. - (optional, defaults to `argument_name`). + - `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 two arguments: - `context`: the context of the current resolution (prior to any modifications of the current middleware). @@ -82,6 +83,7 @@ defmodule AbsintheUtils.Middleware.ArgLoader do @behaviour Absinthe.Middleware alias AbsintheUtils.Helpers.Errors + alias AbsintheUtils.Internal.MapHelpers @impl true def call( @@ -136,20 +138,54 @@ defmodule AbsintheUtils.Middleware.ArgLoader do end end - def load_entities(original_resolution, 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) - nil_is_not_found = Keyword.get(opts, :nil_is_not_found, true) + defp load_entities(original_resolution, arguments, argument_name, opts) + when is_atom(argument_name) do + load_entities(original_resolution, arguments, [argument_name], opts) + end + + 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 + + :error -> + arguments + end + end - {input_value, arguments} = Map.pop!(arguments, argument_name) + 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 nil -> - Map.put(arguments, push_to_key, nil) + nil 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 @@ -157,15 +193,11 @@ defmodule AbsintheUtils.Middleware.ArgLoader 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(_original_resolution, arguments, _argument_name, _opts) do - arguments - end end diff --git a/test/interna/map_helpers_test.exs b/test/interna/map_helpers_test.exs new file mode 100644 index 0000000..4d2dfa6 --- /dev/null +++ b/test/interna/map_helpers_test.exs @@ -0,0 +1,9 @@ +defmodule AbsintheUtils.Internal.MapHelpersTest do + @moduledoc false + + use ExUnit.Case + + alias AbsintheUtils.Internal.MapHelpers + + doctest AbsintheUtils.Internal.MapHelpers +end diff --git a/test/middleware/arg_loader_test.exs b/test/middleware/arg_loader_test.exs index 596cb32..822f566 100644 --- a/test/middleware/arg_loader_test.exs +++ b/test/middleware/arg_loader_test.exs @@ -1,4 +1,6 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do + @moduledoc false + use ExUnit.Case, async: true # use Absinthe.Schema @@ -80,6 +82,15 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do field(:users, non_null(list_of(:user))) end + input_object :complex_input_object do + field(:user1_id, :id) + field(:user2_id, :id) + end + + object :complex_output_object do + field(:processed_input, :two_entities) + end + def resolve_params(_, params, _) do {:ok, params} end @@ -291,6 +302,32 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do resolve(&resolve_params/3) end + + field :complex_input, :complex_output_object do + arg(:complex_input_object, :complex_input_object) + + middleware( + ArgLoader, + %{ + [:complex_input_object, :user1_id] => [ + new_name: [:processed_input, :user1], + load_function: fn _context, input_value -> + SampleRepository.get_user(input_value) + end, + nil_is_not_found: true + ], + [:complex_input_object, :user2_id] => [ + new_name: [:processed_input, :user2], + load_function: fn _context, input_value -> + SampleRepository.get_user(input_value) + end, + nil_is_not_found: true + ] + } + ) + + resolve(&resolve_params/3) + end end end @@ -1330,4 +1367,47 @@ defmodule AbsintheUtilsTest.Middleware.ArgLoaderTest do ) end end + + describe "complex input and output object" do + @query """ + query ($complexInputObject: ComplexInputObject!) { + complexInput(complexInputObject: $complexInputObject) { + processedInput { + user1 { + id + name + } + user2 { + id + name + } + } + } + } + """ + + test "success" do + assert {:ok, + %{ + data: %{ + "complexInput" => %{ + "processedInput" => %{ + "user1" => %{"id" => "2", "name" => "Bob"}, + "user2" => %{"id" => "1", "name" => "Ally"} + } + } + } + }} === + Absinthe.run( + @query, + TestSchema, + variables: %{ + "complexInputObject" => %{ + "user1Id" => "2", + "user2Id" => "1" + } + } + ) + end + end end From 7eabe8f76e46969129214335cc35b9b5fb6aa726 Mon Sep 17 00:00:00 2001 From: Francisco Marques Date: Mon, 5 May 2025 14:51:59 +0100 Subject: [PATCH 5/5] update doc --- lib/middleware/arg_loader.ex | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/lib/middleware/arg_loader.ex b/lib/middleware/arg_loader.ex index 07d4003..5b8b833 100644 --- a/lib/middleware/arg_loader.ex +++ b/lib/middleware/arg_loader.ex @@ -48,6 +48,8 @@ defmodule AbsintheUtils.Middleware.ArgLoader do This will define a `user` query that accepts an `id` input. Before calling the resolver, + ### List of entities + `ArgLoader` can also be used to load a `list_of` arguments: ``` @@ -76,6 +78,40 @@ defmodule AbsintheUtils.Middleware.ArgLoader do end ``` + ### 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. """