diff --git a/lib/absinthe.ex b/lib/absinthe.ex index 45177bae4e..7d57d7c41e 100644 --- a/lib/absinthe.ex +++ b/lib/absinthe.ex @@ -44,9 +44,12 @@ defmodule Absinthe do %{message: String.t()} | %{message: String.t(), locations: [%{line: pos_integer, column: integer}]} + @type continuation_t :: nil | [Continuation.t()] + @type result_t :: - %{data: nil | result_selection_t} - | %{data: nil | result_selection_t, errors: [result_error_t]} + %{required(:data) => nil | result_selection_t, + optional(:continuation) => continuation_t, + optional(:errors) => [result_error_t]} | %{errors: [result_error_t]} @doc """ @@ -95,7 +98,7 @@ defmodule Absinthe do max_complexity: non_neg_integer | :infinity ] - @type run_result :: {:ok, result_t} | {:error, String.t()} + @type run_result :: {:ok, result_t} | {:more, result_t} | {:error, String.t()} @spec run( binary | Absinthe.Language.Source.t() | Absinthe.Language.Document.t(), @@ -107,7 +110,23 @@ defmodule Absinthe do schema |> Absinthe.Pipeline.for_document(options) - case Absinthe.Pipeline.run(document, pipeline) do + document + |> Absinthe.Pipeline.run(pipeline) + |> build_result() + end + + @spec continue([Continuation.t()]) :: run_result() + def continue(continuation) do + continuation + |> Absinthe.Pipeline.continue() + |> build_result() + end + + defp build_result(output) do + case output do + {:ok, %{result: %{continuation: c} = result}, _phases} when c != [] -> + {:more, result} + {:ok, %{result: result}, _phases} -> {:ok, result} @@ -131,6 +150,7 @@ defmodule Absinthe do def run!(input, schema, options \\ []) do case run(input, schema, options) do {:ok, result} -> result + {:more, result} -> result {:error, err} -> raise ExecutionError, message: err end end diff --git a/lib/absinthe/blueprint/continuation.ex b/lib/absinthe/blueprint/continuation.ex new file mode 100644 index 0000000000..d581c88bad --- /dev/null +++ b/lib/absinthe/blueprint/continuation.ex @@ -0,0 +1,19 @@ +defmodule Absinthe.Blueprint.Continuation do + @moduledoc false + + # Continuations allow further resolutions after the initial result is + # returned + + alias Absinthe.Pipeline + + defstruct [ + :phase_input, + :pipeline + ] + + @type t :: %__MODULE__{ + phase_input: Pipeline.data_t, + pipeline: Pipeline.t() + } + +end diff --git a/lib/absinthe/blueprint/document/field.ex b/lib/absinthe/blueprint/document/field.ex index c44f157e22..0b93ff24e7 100644 --- a/lib/absinthe/blueprint/document/field.ex +++ b/lib/absinthe/blueprint/document/field.ex @@ -20,7 +20,9 @@ defmodule Absinthe.Blueprint.Document.Field do complexity: nil, # Set during resolution, this holds the concrete parent type # as determined by the resolution phase. - parent_type: nil + parent_type: nil, + # Added by directives + dynamic_middleware: [] ] @type t :: %__MODULE__{ @@ -33,6 +35,7 @@ defmodule Absinthe.Blueprint.Document.Field do source_location: nil | Blueprint.SourceLocation.t(), type_conditions: [Blueprint.TypeReference.Name], schema_node: Type.t(), - complexity: nil | non_neg_integer + complexity: nil | non_neg_integer, + dynamic_middleware: [] } end diff --git a/lib/absinthe/blueprint/result/list.ex b/lib/absinthe/blueprint/result/list.ex index af78c02ad5..9aa8f59857 100644 --- a/lib/absinthe/blueprint/result/list.ex +++ b/lib/absinthe/blueprint/result/list.ex @@ -9,7 +9,8 @@ defmodule Absinthe.Blueprint.Result.List do :values, errors: [], flags: %{}, - extensions: %{} + extensions: %{}, + continuations: [] ] @type t :: %__MODULE__{ @@ -17,6 +18,7 @@ defmodule Absinthe.Blueprint.Result.List do values: [Blueprint.Document.Resolution.node_t()], errors: [Phase.Error.t()], flags: Blueprint.flags_t(), - extensions: %{any => any} + extensions: %{any => any}, + continuations: [Continuation.t()] } end diff --git a/lib/absinthe/blueprint/result/object.ex b/lib/absinthe/blueprint/result/object.ex index 4f4edf1ab1..b88dc22847 100644 --- a/lib/absinthe/blueprint/result/object.ex +++ b/lib/absinthe/blueprint/result/object.ex @@ -10,7 +10,8 @@ defmodule Absinthe.Blueprint.Result.Object do :fields, errors: [], flags: %{}, - extensions: %{} + extensions: %{}, + continuations: [] ] @type t :: %__MODULE__{ @@ -18,6 +19,7 @@ defmodule Absinthe.Blueprint.Result.Object do fields: [Blueprint.Document.Resolution.node_t()], errors: [Phase.Error.t()], flags: Blueprint.flags_t(), - extensions: %{any => any} + extensions: %{any => any}, + continuations: [Continuation.t()] } end diff --git a/lib/absinthe/middleware/defer.ex b/lib/absinthe/middleware/defer.ex new file mode 100644 index 0000000000..d7308d22be --- /dev/null +++ b/lib/absinthe/middleware/defer.ex @@ -0,0 +1,12 @@ +defmodule Absinthe.Middleware.Defer do + @moduledoc false + + # Suspends deferred fields so that they are not immediately processed + + @behaviour Absinthe.Middleware + + def call(%{state: :unresolved} = res, _), + do: %{res | state: :suspended, acc: Map.put(res.acc, :deferred_res, res)} + + def call(res, _), do: res +end diff --git a/lib/absinthe/phase/document/execution/defer_fields.ex b/lib/absinthe/phase/document/execution/defer_fields.ex new file mode 100644 index 0000000000..b984536958 --- /dev/null +++ b/lib/absinthe/phase/document/execution/defer_fields.ex @@ -0,0 +1,88 @@ +defmodule Absinthe.Phase.Document.Execution.DeferFields do + @moduledoc false + + # Strips out deferred fields from the current result and places them + # in continuations. + + alias Absinthe.Phase.Document.Execution.Resolution + alias Absinthe.{Blueprint, Phase, Resolution} + alias Blueprint.Continuation + alias Blueprint.Result.List + + use Absinthe.Phase + + @spec run(Blueprint.t(), Keyword.t()) :: Phase.result_t() + def run(bp_root, _options \\ []) do + result = strip_deferred(bp_root, bp_root.execution.result) + + {:ok, %{bp_root | execution: %{bp_root.execution | result: result}}} + end + + defp strip_deferred(bp_root, %{fields: _} = object) do + strip_nested(bp_root, object, :fields) + end + + defp strip_deferred(bp_root, %List{} = object) do + strip_nested(bp_root, object, :values) + end + + defp strip_deferred(bp_root, fields) when is_list(fields) do + fields + |> Enum.reduce( + {[], []}, + fn f, acc -> do_strip_deferred(bp_root, f, acc) end + ) + end + + defp strip_deferred(_bp_root, other), do: other + + defp strip_nested(bp_root, object, sub_object_field) do + {continuations, remaining} = strip_deferred(bp_root, Map.get(object, sub_object_field)) + + object + |> Map.put(sub_object_field, Enum.reverse(remaining)) + |> Map.put(:continuations, object.continuations ++ Enum.reverse(continuations)) + end + + defp do_strip_deferred( + bp_root, + %Resolution{state: :suspended, acc: %{deferred_res: res}}, + {deferred, remaining} + ) do + continuation = %Continuation{ + phase_input: %{ + resolution: %{res | state: :unresolved}, + execution: bp_root.execution + }, + pipeline: [ + Phase.Document.Execution.DeferredResolution, + Phase.Document.Execution.DeferFields, + Phase.Document.Result + ] + } + + {[continuation | deferred], remaining} + end + + defp do_strip_deferred(_bp_root, %Resolution{} = r, {deferred, remaining}) do + {deferred, [r | remaining]} + end + + defp do_strip_deferred(bp_root, %{fields: _} = object, acc) do + do_strip_nested(bp_root, object, :fields, acc) + end + + defp do_strip_deferred(bp_root, %List{} = object, acc) do + do_strip_nested(bp_root, object, :values, acc) + end + + defp do_strip_deferred(_bp_root, object, {deferred, remaining}) do + {deferred, [object | remaining]} + end + + defp do_strip_nested(bp_root, object, sub_object_field, {deferred, remaining}) do + {d, r} = strip_deferred(bp_root, Map.get(object, sub_object_field)) + object = Map.put(object, sub_object_field, Enum.reverse(r)) + {d ++ deferred, [object | remaining]} + end +end diff --git a/lib/absinthe/phase/document/execution/deferred_resolution.ex b/lib/absinthe/phase/document/execution/deferred_resolution.ex new file mode 100644 index 0000000000..041f002050 --- /dev/null +++ b/lib/absinthe/phase/document/execution/deferred_resolution.ex @@ -0,0 +1,42 @@ +defmodule Absinthe.Phase.Document.Execution.DeferredResolution do + @moduledoc false + + # Perform resolution on previously deferred fields + + alias Absinthe.{Blueprint, Phase} + alias Phase.Document.Execution.Resolution + + use Absinthe.Phase + + @spec run(map(), Keyword.t()) :: Phase.result_t() + def run(input, _options \\ []) do + {:ok, resolve_field(input)} + end + + defp resolve_field(input) do + {result, _} = perform_deferred_resolution(input) + + %Blueprint{ + execution: %{input.execution | result: result}, + result: %{path: make_path(input.resolution.path)}} + end + + defp perform_deferred_resolution(input) do + # Perform resolution using the standard resolver pipeline functionality + Resolution.do_resolve_field( + input.resolution, + input.resolution.source, + input.resolution.path + ) + end + + defp make_path(path) do + path + |> Enum.map(&to_path_field/1) + |> Enum.filter(fn e -> e != nil end) + |> Enum.reverse() + end + + defp to_path_field(index) when is_integer(index), do: index + defp to_path_field(%{name: name}), do: name +end diff --git a/lib/absinthe/phase/document/execution/resolution.ex b/lib/absinthe/phase/document/execution/resolution.ex index 92e2019123..2e7382d601 100644 --- a/lib/absinthe/phase/document/execution/resolution.ex +++ b/lib/absinthe/phase/document/execution/resolution.ex @@ -180,7 +180,7 @@ defmodule Absinthe.Phase.Document.Execution.Resolution do end # bp_field needs to have a concrete schema node, AKA no unions or interfaces - defp do_resolve_field(res, source, path) do + def do_resolve_field(res, source, path) do res |> reduce_resolution |> case do @@ -218,7 +218,7 @@ defmodule Absinthe.Phase.Document.Execution.Resolution do errors: [], source: source, parent_type: parent_type, - middleware: middleware, + middleware: bp_field.dynamic_middleware ++ middleware, definition: bp_field, arguments: args } diff --git a/lib/absinthe/phase/document/result.ex b/lib/absinthe/phase/document/result.ex index 7a7fcb641e..5e22c3c3c6 100644 --- a/lib/absinthe/phase/document/result.ex +++ b/lib/absinthe/phase/document/result.ex @@ -22,7 +22,9 @@ defmodule Absinthe.Phase.Document.Result do {:validation_failed, errors} end - format_result(result) + result + |> format_result() + |> maybe_add_continuations(blueprint.execution.result) end defp format_result({:ok, {data, []}}) do @@ -118,4 +120,9 @@ defmodule Absinthe.Phase.Document.Result do end defp format_location(_), do: [] + + defp maybe_add_continuations(result, %{continuations: continuations}) when continuations != [], + do: Map.put(result, :continuation, continuations) + + defp maybe_add_continuations(result, _), do: result end diff --git a/lib/absinthe/pipeline.ex b/lib/absinthe/pipeline.ex index de476ca71a..c47ad2087e 100644 --- a/lib/absinthe/pipeline.ex +++ b/lib/absinthe/pipeline.ex @@ -6,23 +6,41 @@ defmodule Absinthe.Pipeline do modifying, and executing pipelines of phases. """ + alias Absinthe.Blueprint.Continuation alias Absinthe.Phase require Logger @type data_t :: any + @type run_result_t :: {:ok, data_t, [Phase.t()]} | {:error, String.t(), [Phase.t()]} + @type phase_config_t :: Phase.t() | {Phase.t(), Keyword.t()} @type t :: [phase_config_t | [phase_config_t]] - @spec run(data_t, t) :: {:ok, data_t, [Phase.t()]} | {:error, String.t(), [Phase.t()]} + @spec run(data_t, t) :: run_result_t def run(input, pipeline) do pipeline |> List.flatten() |> run_phase(input) end + @spec continue([Continuation.t()]) :: run_result_t + def continue([continuation | rest]) do + result = run_phase(continuation.pipeline, continuation.phase_input) + + case result do + {:ok, blueprint, phases} when rest == [] -> + {:ok, blueprint, phases} + {:ok, blueprint, phases} -> + bp_result = Map.put(blueprint.result, :continuation, rest) + blueprint = Map.put(blueprint, :result, bp_result) + {:ok, blueprint, phases} + error -> error + end + end + @defaults [ adapter: Absinthe.Adapter.LanguageConventions, operation_name: nil, @@ -105,6 +123,7 @@ defmodule Absinthe.Pipeline do # Execution {Phase.Subscription.SubscribeSelf, options}, {Phase.Document.Execution.Resolution, options}, + Phase.Document.Execution.DeferFields, # Format Result Phase.Document.Result, {Phase.Telemetry, [:execute, :operation, :stop, options]} @@ -353,8 +372,8 @@ defmodule Absinthe.Pipeline do end) end - @spec run_phase(t, data_t, [Phase.t()]) :: - {:ok, data_t, [Phase.t()]} | {:error, String.t(), [Phase.t()]} + @spec run_phase(t, data_t, [Phase.t()]) :: run_result_t + def run_phase(pipeline, input, done \\ []) def run_phase([], input, done) do diff --git a/lib/absinthe/type/built_ins/directives.ex b/lib/absinthe/type/built_ins/directives.ex index 868443b306..3f7e0e5832 100644 --- a/lib/absinthe/type/built_ins/directives.ex +++ b/lib/absinthe/type/built_ins/directives.ex @@ -39,4 +39,20 @@ defmodule Absinthe.Type.BuiltIns.Directives do Blueprint.put_flag(node, :include, __MODULE__) end end + + directive :defer do + description """ + Directs the executor that it may defer evaluation of this field and send + the response later. + """ + + on [:field, :fragment_spread, :inline_fragment] + + expand fn _, node -> add_defer(node) end + end + + defp add_defer(node) do + node = Blueprint.put_flag(node, :defer, __MODULE__) + %{node | dynamic_middleware: [{Absinthe.Middleware.Defer, []} | node.dynamic_middleware]} + end end diff --git a/test/absinthe/defer_test.exs b/test/absinthe/defer_test.exs new file mode 100644 index 0000000000..557a40722c --- /dev/null +++ b/test/absinthe/defer_test.exs @@ -0,0 +1,224 @@ +defmodule Absinthe.DeferTest do + use ExUnit.Case, async: false + + defmodule Schema do + use Absinthe.Schema + + object :user do + field :id, :id, resolve: &deferrable_resolver/3 + field :name, :string, resolve: &deferrable_resolver/3 + field :friends, list_of(:user), resolve: &friends_resolver/3 + field :address, :address, resolve: &address_resolver/3 + end + + object :address do + field :street, :string + field :town, :string + end + + query do + field :user, :user do + resolve fn _, _, _ -> {:ok, %{name: "user"}} end + end + end + + defp deferrable_resolver(%{name: name}, _, %{definition: %{name: field}}) do + {:ok, name <> "_" <> field} + end + + defp friends_resolver(_, _, _) do + {:ok, Enum.map(1..5, fn x -> %{name: to_string(x)} end)} + end + + defp address_resolver(_, _, _) do + {:ok, %{street: "Street", town: "Town"}} + end + end + + test "simple @defer directive" do + doc = """ + { user { id @defer, name } } + """ + + {:more, %{data: data, continuation: cont}} = Absinthe.run(doc, Schema) + + assert data == %{"user" => %{"name" => "user_name"}} + refute is_nil(cont) + + {:ok, %{data: data, path: path}} = Absinthe.continue(cont) + + assert data == "user_id" + assert path == ["user", "id"] + end + + test "top level @defer directive" do + doc = """ + { user @defer { id, name } } + """ + + {:more, %{continuation: cont, data: data}} = Absinthe.run(doc, Schema) + + assert data == %{} + refute is_nil(cont) + + {:ok, %{data: data, path: path}} = Absinthe.continue(cont) + assert data == %{"name" => "user_name", "id" => "user_id"} + assert path == ["user"] + end + + test "list field @defer directive" do + doc = """ + { user { friends @defer { name } } } + """ + + {:more, %{continuation: cont, data: data}} = Absinthe.run(doc, Schema) + + assert data == %{"user" => %{}} + refute is_nil(cont) + + {:ok, %{data: data, path: path}} = Absinthe.continue(cont) + + assert data == Enum.map(1..5, fn x -> %{"name" => to_string(x) <> "_name"} end) + assert path == ["user", "friends"] + end + + test "list element @defer directive" do + doc = """ + { user { friends { name @defer } } } + """ + + {:more, %{continuation: cont, data: data}} = Absinthe.run(doc, Schema) + + assert data == %{"user" => %{"friends" => List.duplicate(%{}, 5)}} + refute is_nil(cont) + + cont = + Enum.reduce(1..4, cont, fn n, c -> + {:more, %{continuation: cont, data: data, path: path}} = Absinthe.continue(c) + assert data == to_string(n) <> "_name" + assert path == ["user", "friends", n - 1, "name"] + refute is_nil(cont) + cont + end) + + {:ok, %{data: data, path: path}} = Absinthe.continue(cont) + assert data == "5_name" + assert path == ["user", "friends", 4, "name"] + end + + test "nested defers are handled" do + doc = """ + { user { friends @defer { name @defer } } } + """ + + {:more, %{continuation: cont, data: data}} = Absinthe.run(doc, Schema) + + assert data == %{"user" => %{}} + refute is_nil(cont) + + {:more, %{continuation: cont, data: data, path: path}} = Absinthe.continue(cont) + + assert data == List.duplicate(%{}, 5) + assert path == ["user", "friends"] + + cont = + Enum.reduce(1..4, cont, fn n, c -> + {:more, %{continuation: cont, data: data, path: path}} = Absinthe.continue(c) + assert data == to_string(n) <> "_name" + assert path == ["user", "friends", n - 1, "name"] + refute is_nil(cont) + cont + end) + + {:ok, %{data: data, path: path}} = Absinthe.continue(cont) + assert data == "5_name" + assert path == ["user", "friends", 4, "name"] + end + + test "pre-resolved fields are correctly deferred" do + doc = """ + { user { address { street, town @defer } } } + """ + + {:more, %{continuation: cont, data: data}} = Absinthe.run(doc, Schema) + + assert data == %{"user" => %{"address" => %{"street" => "Street"}}} + refute is_nil(cont) + + {:ok, %{data: data, path: path}} = Absinthe.continue(cont) + + assert data == "Town" + assert path == ["user", "address", "town"] + end + + test "deferred fragment" do + doc = """ + { + user @defer { + ...addressPart + } + } + + fragment addressPart on User { + address { + street + } + } + """ + + {:more, %{continuation: cont, data: data}} = Absinthe.run(doc, Schema) + + assert data == %{} + refute is_nil(cont) + + {:ok, %{data: data, path: path}} = Absinthe.continue(cont) + + assert data == %{"address" => %{"street" => "Street"}} + assert path == ["user"] + end + + test "deferred fragment part" do + doc = """ + { + user { + ...addressPart + } + } + + fragment addressPart on User { + address { + street @defer + } + } + """ + + {:more, %{continuation: cont, data: data}} = Absinthe.run(doc, Schema) + + assert data == %{"user" => %{"address" => %{}}} + refute is_nil(cont) + + {:ok, %{data: data, path: path}} = Absinthe.continue(cont) + + assert data == "Street" + assert path == ["user", "address", "street"] + end + + test "multiple defers" do + doc = "{ user { id @defer, name @defer } }" + + {:more, %{continuation: cont, data: data}} = Absinthe.run(doc, Schema) + + refute is_nil(cont) + assert data == %{"user" => %{}} + + {:more, %{continuation: cont, data: data, path: path}} = Absinthe.continue(cont) + + refute is_nil(cont) + assert data == "user_id" + assert path == ["user", "id"] + + {:ok, %{data: data, path: path}} = Absinthe.continue(cont) + assert data == "user_name" + assert path == ["user", "name"] + end +end diff --git a/test/absinthe/integration/execution/introspection/directives_test.exs b/test/absinthe/integration/execution/introspection/directives_test.exs index 99541cf42d..ab446b3338 100644 --- a/test/absinthe/integration/execution/introspection/directives_test.exs +++ b/test/absinthe/integration/execution/introspection/directives_test.exs @@ -22,6 +22,14 @@ defmodule Elixir.Absinthe.Integration.Execution.Introspection.DirectivesTest do data: %{ "__schema" => %{ "directives" => [ + %{ + "args" => [], + "name" => "defer", + "locations" => ["FIELD", "FRAGMENT_SPREAD", "INLINE_FRAGMENT"], + "onField" => true, + "onFragment" => true, + "onOperation" => false + }, %{ "args" => [ %{ diff --git a/test/absinthe/phase/document/validation/known_directives_test.exs b/test/absinthe/phase/document/validation/known_directives_test.exs index f20ce5782e..4b46f12d8a 100644 --- a/test/absinthe/phase/document/validation/known_directives_test.exs +++ b/test/absinthe/phase/document/validation/known_directives_test.exs @@ -51,6 +51,9 @@ defmodule Absinthe.Phase.Document.Validation.KnownDirectivesTest do human @skip(if: false) { name } + cat @defer { + name + } } """, []