From d9c5e1dedb682264788c7c681529268422c987d1 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Sun, 19 Jan 2025 16:50:36 +0100 Subject: [PATCH] incremental delivery: add pending notifications Replicates graphql/graphql-js@fe65bc8be6813d03870ba0d7faffa733c1ba7351 --- docs/conf.py | 4 + .../execution/incremental_publisher.py | 225 ++++++++++---- tests/execution/test_defer.py | 281 +++++++++++++++--- tests/execution/test_execution_result.py | 12 +- tests/execution/test_mutations.py | 12 +- tests/execution/test_stream.py | 88 +++++- 6 files changed, 507 insertions(+), 115 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index d3de91ea..1d7afde0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -153,6 +153,7 @@ ExperimentalIncrementalExecutionResults FieldGroup FormattedIncrementalResult +FormattedPendingResult FormattedSourceLocation GraphQLAbstractType GraphQLCompositeType @@ -167,6 +168,7 @@ IncrementalResult InitialResultRecord Middleware +PendingResult StreamItemsRecord StreamRecord SubsequentDataRecord @@ -183,8 +185,10 @@ graphql.execution.incremental_publisher.DeferredFragmentRecord graphql.execution.incremental_publisher.DeferredGroupedFieldSetRecord graphql.execution.incremental_publisher.FormattedCompletedResult +graphql.execution.incremental_publisher.FormattedPendingResult graphql.execution.incremental_publisher.IncrementalPublisher graphql.execution.incremental_publisher.InitialResultRecord +graphql.execution.incremental_publisher.PendingResult graphql.execution.incremental_publisher.StreamItemsRecord graphql.execution.incremental_publisher.StreamRecord graphql.execution.Middleware diff --git a/src/graphql/execution/incremental_publisher.py b/src/graphql/execution/incremental_publisher.py index 18890fb3..4ba1d553 100644 --- a/src/graphql/execution/incremental_publisher.py +++ b/src/graphql/execution/incremental_publisher.py @@ -13,7 +13,6 @@ Collection, Iterator, NamedTuple, - Sequence, Union, ) @@ -22,6 +21,8 @@ except ImportError: # Python < 3.8 from typing_extensions import TypedDict +from ..pyutils import RefSet + if TYPE_CHECKING: from ..error import GraphQLError, GraphQLFormattedError from ..pyutils import Path @@ -55,6 +56,63 @@ suppress_key_error = suppress(KeyError) +class FormattedPendingResult(TypedDict, total=False): + """Formatted pending execution result""" + + path: list[str | int] + label: str + + +class PendingResult: + """Pending execution result""" + + path: list[str | int] + label: str | None + + __slots__ = "label", "path" + + def __init__( + self, + path: list[str | int], + label: str | None = None, + ) -> None: + self.path = path + self.label = label + + def __repr__(self) -> str: + name = self.__class__.__name__ + args: list[str] = [f"path={self.path!r}"] + if self.label: + args.append(f"label={self.label!r}") + return f"{name}({', '.join(args)})" + + @property + def formatted(self) -> FormattedPendingResult: + """Get pending result formatted according to the specification.""" + formatted: FormattedPendingResult = {"path": self.path} + if self.label is not None: + formatted["label"] = self.label + return formatted + + def __eq__(self, other: object) -> bool: + if isinstance(other, dict): + return (other.get("path") or None) == (self.path or None) and ( + other.get("label") or None + ) == (self.label or None) + + if isinstance(other, tuple): + size = len(other) + return 1 < size < 3 and (self.path, self.label)[:size] == other + return ( + isinstance(other, self.__class__) + and other.path == self.path + and other.label == self.label + ) + + def __ne__(self, other: object) -> bool: + return not self == other + + class FormattedCompletedResult(TypedDict, total=False): """Formatted completed execution result""" @@ -93,7 +151,7 @@ def __repr__(self) -> str: @property def formatted(self) -> FormattedCompletedResult: - """Get execution result formatted according to the specification.""" + """Get completed result formatted according to the specification.""" formatted: FormattedCompletedResult = {"path": self.path} if self.label is not None: formatted["label"] = self.label @@ -104,9 +162,9 @@ def formatted(self) -> FormattedCompletedResult: def __eq__(self, other: object) -> bool: if isinstance(other, dict): return ( - other.get("path") == self.path - and ("label" not in other or other["label"] == self.label) - and ("errors" not in other or other["errors"] == self.errors) + (other.get("path") or None) == (self.path or None) + and (other.get("label") or None) == (self.label or None) + and (other.get("errors") or None) == (self.errors or None) ) if isinstance(other, tuple): size = len(other) @@ -125,6 +183,7 @@ def __ne__(self, other: object) -> bool: class IncrementalUpdate(NamedTuple): """Incremental update""" + pending: list[PendingResult] incremental: list[IncrementalResult] completed: list[CompletedResult] @@ -181,13 +240,11 @@ def formatted(self) -> FormattedExecutionResult: def __eq__(self, other: object) -> bool: if isinstance(other, dict): - if "extensions" not in other: - return other == {"data": self.data, "errors": self.errors} - return other == { - "data": self.data, - "errors": self.errors, - "extensions": self.extensions, - } + return ( + (other.get("data") == self.data) + and (other.get("errors") or None) == (self.errors or None) + and (other.get("extensions") or None) == (self.extensions or None) + ) if isinstance(other, tuple): if len(other) == 2: return other == (self.data, self.errors) @@ -208,40 +265,42 @@ class FormattedInitialIncrementalExecutionResult(TypedDict, total=False): data: dict[str, Any] | None errors: list[GraphQLFormattedError] + pending: list[FormattedPendingResult] hasNext: bool incremental: list[FormattedIncrementalResult] extensions: dict[str, Any] class InitialIncrementalExecutionResult: - """Initial incremental execution result. - - - ``has_next`` is True if a future payload is expected. - - ``incremental`` is a list of the results from defer/stream directives. - """ + """Initial incremental execution result.""" data: dict[str, Any] | None errors: list[GraphQLError] | None + pending: list[PendingResult] has_next: bool extensions: dict[str, Any] | None - __slots__ = "data", "errors", "extensions", "has_next" + __slots__ = "data", "errors", "extensions", "has_next", "pending" def __init__( self, data: dict[str, Any] | None = None, errors: list[GraphQLError] | None = None, + pending: list[PendingResult] | None = None, has_next: bool = False, extensions: dict[str, Any] | None = None, ) -> None: self.data = data self.errors = errors + self.pending = pending or [] self.has_next = has_next self.extensions = extensions def __repr__(self) -> str: name = self.__class__.__name__ args: list[str] = [f"data={self.data!r}, errors={self.errors!r}"] + if self.pending: + args.append(f"pending={self.pending!r}") if self.has_next: args.append("has_next") if self.extensions: @@ -254,6 +313,7 @@ def formatted(self) -> FormattedInitialIncrementalExecutionResult: formatted: FormattedInitialIncrementalExecutionResult = {"data": self.data} if self.errors is not None: formatted["errors"] = [error.formatted for error in self.errors] + formatted["pending"] = [pending.formatted for pending in self.pending] formatted["hasNext"] = self.has_next if self.extensions is not None: formatted["extensions"] = self.extensions @@ -263,19 +323,19 @@ def __eq__(self, other: object) -> bool: if isinstance(other, dict): return ( other.get("data") == self.data - and other.get("errors") == self.errors - and ("hasNext" not in other or other["hasNext"] == self.has_next) - and ( - "extensions" not in other or other["extensions"] == self.extensions - ) + and (other.get("errors") or None) == (self.errors or None) + and (other.get("pending") or None) == (self.pending or None) + and (other.get("hasNext") or None) == (self.has_next or None) + and (other.get("extensions") or None) == (self.extensions or None) ) if isinstance(other, tuple): size = len(other) return ( - 1 < size < 5 + 1 < size < 6 and ( self.data, self.errors, + self.pending, self.has_next, self.extensions, )[:size] @@ -285,6 +345,7 @@ def __eq__(self, other: object) -> bool: isinstance(other, self.__class__) and other.data == self.data and other.errors == self.errors + and other.pending == self.pending and other.has_next == self.has_next and other.extensions == self.extensions ) @@ -356,11 +417,9 @@ def __eq__(self, other: object) -> bool: if isinstance(other, dict): return ( other.get("data") == self.data - and other.get("errors") == self.errors - and ("path" not in other or other["path"] == self.path) - and ( - "extensions" not in other or other["extensions"] == self.extensions - ) + and (other.get("errors") or None) == (self.errors or None) + and (other.get("path") or None) == (self.path or None) + and (other.get("extensions") or None) == (self.extensions or None) ) if isinstance(other, tuple): size = len(other) @@ -435,12 +494,10 @@ def formatted(self) -> FormattedIncrementalStreamResult: def __eq__(self, other: object) -> bool: if isinstance(other, dict): return ( - other.get("items") == self.items - and other.get("errors") == self.errors - and ("path" not in other or other["path"] == self.path) - and ( - "extensions" not in other or other["extensions"] == self.extensions - ) + (other.get("items") or None) == (self.items or None) + and (other.get("errors") or None) == (self.errors or None) + and (other.get("path", None) == (self.path or None)) + and (other.get("extensions", None) == (self.extensions or None)) ) if isinstance(other, tuple): size = len(other) @@ -472,33 +529,33 @@ class FormattedSubsequentIncrementalExecutionResult(TypedDict, total=False): """Formatted subsequent incremental execution result""" hasNext: bool + pending: list[FormattedPendingResult] incremental: list[FormattedIncrementalResult] completed: list[FormattedCompletedResult] extensions: dict[str, Any] class SubsequentIncrementalExecutionResult: - """Subsequent incremental execution result. - - - ``has_next`` is True if a future payload is expected. - - ``incremental`` is a list of the results from defer/stream directives. - """ + """Subsequent incremental execution result.""" - __slots__ = "completed", "extensions", "has_next", "incremental" + __slots__ = "completed", "extensions", "has_next", "incremental", "pending" has_next: bool - incremental: Sequence[IncrementalResult] | None - completed: Sequence[CompletedResult] | None + pending: list[PendingResult] | None + incremental: list[IncrementalResult] | None + completed: list[CompletedResult] | None extensions: dict[str, Any] | None def __init__( self, has_next: bool = False, - incremental: Sequence[IncrementalResult] | None = None, - completed: Sequence[CompletedResult] | None = None, + pending: list[PendingResult] | None = None, + incremental: list[IncrementalResult] | None = None, + completed: list[CompletedResult] | None = None, extensions: dict[str, Any] | None = None, ) -> None: self.has_next = has_next + self.pending = pending or [] self.incremental = incremental self.completed = completed self.extensions = extensions @@ -508,6 +565,8 @@ def __repr__(self) -> str: args: list[str] = [] if self.has_next: args.append("has_next") + if self.pending: + args.append(f"pending[{len(self.pending)}]") if self.incremental: args.append(f"incremental[{len(self.incremental)}]") if self.completed: @@ -521,6 +580,8 @@ def formatted(self) -> FormattedSubsequentIncrementalExecutionResult: """Get execution result formatted according to the specification.""" formatted: FormattedSubsequentIncrementalExecutionResult = {} formatted["hasNext"] = self.has_next + if self.pending: + formatted["pending"] = [result.formatted for result in self.pending] if self.incremental: formatted["incremental"] = [result.formatted for result in self.incremental] if self.completed: @@ -532,22 +593,19 @@ def formatted(self) -> FormattedSubsequentIncrementalExecutionResult: def __eq__(self, other: object) -> bool: if isinstance(other, dict): return ( - ("hasNext" in other and other["hasNext"] == self.has_next) - and ( - "incremental" not in other - or other["incremental"] == self.incremental - ) - and ("completed" not in other or other["completed"] == self.completed) - and ( - "extensions" not in other or other["extensions"] == self.extensions - ) + (other.get("hasNext") or None) == (self.has_next or None) + and (other.get("pending") or None) == (self.pending or None) + and (other.get("incremental") or None) == (self.incremental or None) + and (other.get("completed") or None) == (self.completed or None) + and (other.get("extensions") or None) == (self.extensions or None) ) if isinstance(other, tuple): size = len(other) return ( - 1 < size < 5 + 1 < size < 6 and ( self.has_next, + self.pending, self.incremental, self.completed, self.extensions, @@ -557,6 +615,7 @@ def __eq__(self, other: object) -> bool: return ( isinstance(other, self.__class__) and other.has_next == self.has_next + and self.pending == other.pending and other.incremental == self.incremental and other.completed == self.completed and other.extensions == self.extensions @@ -729,11 +788,19 @@ def build_data_response( error.message, ) ) - if self._pending: + pending = self._pending + if pending: + pending_sources: RefSet[DeferredFragmentRecord | StreamRecord] = RefSet( + subsequent_result_record.stream_record + if isinstance(subsequent_result_record, StreamItemsRecord) + else subsequent_result_record + for subsequent_result_record in pending + ) return ExperimentalIncrementalExecutionResults( initial_result=InitialIncrementalExecutionResult( data, errors, + pending=self._pending_sources_to_results(pending_sources), has_next=True, ), subsequent_results=self._subscribe(), @@ -783,6 +850,19 @@ def filter( if early_returns: self._add_task(gather(*early_returns)) + def _pending_sources_to_results( + self, + pending_sources: RefSet[DeferredFragmentRecord | StreamRecord], + ) -> list[PendingResult]: + """Convert pending sources to pending results.""" + pending_results: list[PendingResult] = [] + for pending_source in pending_sources: + pending_source.pending_sent = True + pending_results.append( + PendingResult(pending_source.path, pending_source.label) + ) + return pending_results + async def _subscribe( self, ) -> AsyncGenerator[SubsequentIncrementalExecutionResult, None]: @@ -854,14 +934,18 @@ def _get_incremental_result( ) -> SubsequentIncrementalExecutionResult | None: """Get the incremental result with the completed records.""" update = self._process_pending(completed_records) - incremental, completed = update.incremental, update.completed + pending, incremental, completed = ( + update.pending, + update.incremental, + update.completed, + ) has_next = bool(self._pending) if not incremental and not completed and has_next: return None return SubsequentIncrementalExecutionResult( - has_next, incremental or None, completed or None + has_next, pending or None, incremental or None, completed or None ) def _process_pending( @@ -869,6 +953,7 @@ def _process_pending( completed_records: Collection[SubsequentResultRecord], ) -> IncrementalUpdate: """Process the pending records.""" + new_pending_sources: RefSet[DeferredFragmentRecord | StreamRecord] = RefSet() incremental_results: list[IncrementalResult] = [] completed_results: list[CompletedResult] = [] to_result = self._completed_record_to_result @@ -876,13 +961,20 @@ def _process_pending( for child in subsequent_result_record.children: if child.filtered: continue + pending_source: DeferredFragmentRecord | StreamRecord = ( + child.stream_record + if isinstance(child, StreamItemsRecord) + else child + ) + if not pending_source.pending_sent: + new_pending_sources.add(pending_source) self._publish(child) incremental_result: IncrementalResult if isinstance(subsequent_result_record, StreamItemsRecord): if subsequent_result_record.is_final_record: - completed_results.append( - to_result(subsequent_result_record.stream_record) - ) + stream_record = subsequent_result_record.stream_record + new_pending_sources.discard(stream_record) + completed_results.append(to_result(stream_record)) if subsequent_result_record.is_completed_async_iterator: # async iterable resolver finished but there may be pending payload continue @@ -895,6 +987,7 @@ def _process_pending( ) incremental_results.append(incremental_result) else: + new_pending_sources.discard(subsequent_result_record) completed_results.append(to_result(subsequent_result_record)) if subsequent_result_record.errors: continue @@ -909,7 +1002,11 @@ def _process_pending( deferred_grouped_field_set_record.path, ) incremental_results.append(incremental_result) - return IncrementalUpdate(incremental_results, completed_results) + return IncrementalUpdate( + self._pending_sources_to_results(new_pending_sources), + incremental_results, + completed_results, + ) @staticmethod def _completed_record_to_result( @@ -1052,6 +1149,7 @@ class DeferredFragmentRecord: deferred_grouped_field_set_records: dict[DeferredGroupedFieldSetRecord, None] errors: list[GraphQLError] filtered: bool + pending_sent: bool _pending: dict[DeferredGroupedFieldSetRecord, None] def __init__(self, path: Path | None = None, label: str | None = None) -> None: @@ -1059,6 +1157,7 @@ def __init__(self, path: Path | None = None, label: str | None = None) -> None: self.label = label self.children = {} self.filtered = False + self.pending_sent = False self.deferred_grouped_field_set_records = {} self.errors = [] self._pending = {} @@ -1080,6 +1179,7 @@ class StreamRecord: path: list[str | int] errors: list[GraphQLError] early_return: Callable[[], Awaitable[Any]] | None + pending_sent: bool def __init__( self, @@ -1091,6 +1191,7 @@ def __init__( self.label = label self.errors = [] self.early_return = early_return + self.pending_sent = False def __repr__(self) -> str: name = self.__class__.__name__ diff --git a/tests/execution/test_defer.py b/tests/execution/test_defer.py index d6d17105..2de10173 100644 --- a/tests/execution/test_defer.py +++ b/tests/execution/test_defer.py @@ -1,7 +1,7 @@ from __future__ import annotations from asyncio import sleep -from typing import Any, AsyncGenerator, NamedTuple +from typing import Any, AsyncGenerator, NamedTuple, cast import pytest @@ -10,6 +10,7 @@ ExecutionResult, ExperimentalIncrementalExecutionResults, IncrementalDeferResult, + IncrementalResult, InitialIncrementalExecutionResult, SubsequentIncrementalExecutionResult, execute, @@ -19,6 +20,7 @@ CompletedResult, DeferredFragmentRecord, DeferredGroupedFieldSetRecord, + PendingResult, StreamItemsRecord, StreamRecord, ) @@ -193,6 +195,31 @@ def modified_args(args: dict[str, Any], **modifications: Any) -> dict[str, Any]: def describe_execute_defer_directive(): + def can_format_and_print_pending_result(): + result = PendingResult([]) + assert result.formatted == {"path": []} + assert str(result) == "PendingResult(path=[])" + + result = PendingResult(path=["foo", 1], label="bar") + assert result.formatted == { + "path": ["foo", 1], + "label": "bar", + } + assert str(result) == "PendingResult(path=['foo', 1], label='bar')" + + def can_compare_pending_result(): + args: dict[str, Any] = {"path": ["foo", 1], "label": "bar"} + result = PendingResult(**args) + assert result == PendingResult(**args) + assert result != CompletedResult(**modified_args(args, path=["foo", 2])) + assert result != CompletedResult(**modified_args(args, label="baz")) + assert result == tuple(args.values()) + assert result != tuple(args.values())[:1] + assert result != tuple(args.values())[:1] + ("baz",) + assert result == args + assert result != {**args, "path": ["foo", 2]} + assert result != {**args, "label": "baz"} + def can_format_and_print_completed_result(): result = CompletedResult([]) assert result.formatted == {"path": []} @@ -224,10 +251,9 @@ def can_compare_completed_result(): assert result == tuple(args.values())[:2] assert result != tuple(args.values())[:1] assert result == args - assert result == dict(list(args.items())[:2]) - assert result != dict( - list(args.items())[:1] + [("errors", [GraphQLError("oops")])] - ) + assert result != {**args, "path": ["foo", 2]} + assert result != {**args, "label": "baz"} + assert result != {**args, "errors": [{"message": "oops"}]} def can_format_and_print_incremental_defer_result(): result = IncrementalDeferResult() @@ -276,20 +302,20 @@ def can_compare_incremental_defer_result(): assert result != tuple(args.values())[:1] assert result != ({"hello": "world"}, []) assert result == args - assert result == dict(list(args.items())[:2]) - assert result == dict(list(args.items())[:3]) - assert result != dict(list(args.items())[:2] + [("path", ["foo", 2])]) - assert result != {**args, "extensions": {"baz": 3}} + assert result != {**args, "data": {"hello": "foo"}} + assert result != {**args, "errors": []} + assert result != {**args, "path": ["foo", 2]} + assert result != {**args, "extensions": {"baz": 1}} def can_format_and_print_initial_incremental_execution_result(): result = InitialIncrementalExecutionResult() - assert result.formatted == {"data": None, "hasNext": False} + assert result.formatted == {"data": None, "hasNext": False, "pending": []} assert ( str(result) == "InitialIncrementalExecutionResult(data=None, errors=None)" ) result = InitialIncrementalExecutionResult(has_next=True) - assert result.formatted == {"data": None, "hasNext": True} + assert result.formatted == {"data": None, "hasNext": True, "pending": []} assert ( str(result) == "InitialIncrementalExecutionResult(data=None, errors=None, has_next)" @@ -298,25 +324,28 @@ def can_format_and_print_initial_incremental_execution_result(): result = InitialIncrementalExecutionResult( data={"hello": "world"}, errors=[GraphQLError("msg")], + pending=[PendingResult(["bar"])], has_next=True, extensions={"baz": 2}, ) assert result.formatted == { "data": {"hello": "world"}, - "errors": [GraphQLError("msg")], + "errors": [{"message": "msg"}], + "pending": [{"path": ["bar"]}], "hasNext": True, "extensions": {"baz": 2}, } assert ( str(result) == "InitialIncrementalExecutionResult(" - "data={'hello': 'world'}, errors=[GraphQLError('msg')], has_next," - " extensions={'baz': 2})" + "data={'hello': 'world'}, errors=[GraphQLError('msg')]," + " pending=[PendingResult(path=['bar'])], has_next, extensions={'baz': 2})" ) def can_compare_initial_incremental_execution_result(): args: dict[str, Any] = { "data": {"hello": "world"}, "errors": [GraphQLError("msg")], + "pending": [PendingResult(["bar"])], "has_next": True, "extensions": {"baz": 2}, } @@ -328,6 +357,9 @@ def can_compare_initial_incremental_execution_result(): assert result != InitialIncrementalExecutionResult( **modified_args(args, errors=[]) ) + assert result != InitialIncrementalExecutionResult( + **modified_args(args, pending=[]) + ) assert result != InitialIncrementalExecutionResult( **modified_args(args, has_next=False) ) @@ -335,6 +367,7 @@ def can_compare_initial_incremental_execution_result(): **modified_args(args, extensions={"baz": 1}) ) assert result == tuple(args.values()) + assert result == tuple(args.values())[:5] assert result == tuple(args.values())[:4] assert result == tuple(args.values())[:3] assert result == tuple(args.values())[:2] @@ -344,20 +377,40 @@ def can_compare_initial_incremental_execution_result(): assert result == { "data": {"hello": "world"}, "errors": [GraphQLError("msg")], + "pending": [PendingResult(["bar"])], "hasNext": True, "extensions": {"baz": 2}, } - assert result == { + assert result != { + "errors": [GraphQLError("msg")], + "pending": [PendingResult(["bar"])], + "hasNext": True, + "extensions": {"baz": 2}, + } + assert result != { + "data": {"hello": "world"}, + "pending": [PendingResult(["bar"])], + "hasNext": True, + "extensions": {"baz": 2}, + } + assert result != { "data": {"hello": "world"}, "errors": [GraphQLError("msg")], "hasNext": True, + "extensions": {"baz": 2}, } assert result != { "data": {"hello": "world"}, "errors": [GraphQLError("msg")], - "hasNext": False, + "pending": [PendingResult(["bar"])], "extensions": {"baz": 2}, } + assert result != { + "data": {"hello": "world"}, + "errors": [GraphQLError("msg")], + "pending": [PendingResult(["bar"])], + "hasNext": True, + } def can_format_and_print_subsequent_incremental_execution_result(): result = SubsequentIncrementalExecutionResult() @@ -368,36 +421,44 @@ def can_format_and_print_subsequent_incremental_execution_result(): assert result.formatted == {"hasNext": True} assert str(result) == "SubsequentIncrementalExecutionResult(has_next)" - incremental = [IncrementalDeferResult()] + pending = [PendingResult(["bar"])] + incremental = [cast(IncrementalResult, IncrementalDeferResult())] completed = [CompletedResult(["foo", 1])] result = SubsequentIncrementalExecutionResult( has_next=True, + pending=pending, incremental=incremental, completed=completed, extensions={"baz": 2}, ) assert result.formatted == { "hasNext": True, + "pending": [{"path": ["bar"]}], "incremental": [{"data": None}], "completed": [{"path": ["foo", 1]}], "extensions": {"baz": 2}, } assert ( str(result) == "SubsequentIncrementalExecutionResult(has_next," - " incremental[1], completed[1], extensions={'baz': 2})" + " pending[1], incremental[1], completed[1], extensions={'baz': 2})" ) def can_compare_subsequent_incremental_execution_result(): - incremental = [IncrementalDeferResult()] + pending = [PendingResult(["bar"])] + incremental = [cast(IncrementalResult, IncrementalDeferResult())] completed = [CompletedResult(path=["foo", 1])] args: dict[str, Any] = { "has_next": True, + "pending": pending, "incremental": incremental, "completed": completed, "extensions": {"baz": 2}, } result = SubsequentIncrementalExecutionResult(**args) assert result == SubsequentIncrementalExecutionResult(**args) + assert result != SubsequentIncrementalExecutionResult( + **modified_args(args, pending=[]) + ) assert result != SubsequentIncrementalExecutionResult( **modified_args(args, incremental=[]) ) @@ -408,22 +469,47 @@ def can_compare_subsequent_incremental_execution_result(): **modified_args(args, extensions={"baz": 1}) ) assert result == tuple(args.values()) + assert result == tuple(args.values())[:3] assert result == tuple(args.values())[:2] assert result != tuple(args.values())[:1] assert result != (incremental, False) assert result == { "hasNext": True, + "pending": pending, "incremental": incremental, "completed": completed, "extensions": {"baz": 2}, } - assert result == {"incremental": incremental, "hasNext": True} assert result != { - "hasNext": False, + "pending": pending, + "incremental": incremental, + "completed": completed, + "extensions": {"baz": 2}, + } + assert result != { + "hasNext": True, "incremental": incremental, "completed": completed, "extensions": {"baz": 2}, } + assert result != { + "hasNext": True, + "pending": pending, + "completed": completed, + "extensions": {"baz": 2}, + } + assert result != { + "hasNext": True, + "pending": pending, + "incremental": incremental, + "extensions": {"baz": 2}, + } + assert result != { + "hasNext": True, + "pending": pending, + "incremental": incremental, + "completed": completed, + } def can_print_deferred_grouped_field_set_record(): record = DeferredGroupedFieldSetRecord([], {}, False) @@ -483,7 +569,11 @@ async def can_defer_fragments_containing_scalar_types(): result = await complete(document) assert result == [ - {"data": {"hero": {"id": "1"}}, "hasNext": True}, + { + "data": {"hero": {"id": "1"}}, + "pending": [{"path": ["hero"]}], + "hasNext": True, + }, { "incremental": [{"data": {"name": "Luke"}, "path": ["hero"]}], "completed": [{"path": ["hero"]}], @@ -535,7 +625,11 @@ async def does_not_disable_defer_with_null_if_argument(): result = await complete(document) assert result == [ - {"data": {"hero": {"id": "1"}}, "hasNext": True}, + { + "data": {"hero": {"id": "1"}}, + "pending": [{"path": ["hero"]}], + "hasNext": True, + }, { "incremental": [{"data": {"name": "Luke"}, "path": ["hero"]}], "completed": [{"path": ["hero"]}], @@ -581,7 +675,11 @@ async def can_defer_fragments_on_the_top_level_query_field(): result = await complete(document) assert result == [ - {"data": {}, "hasNext": True}, + { + "data": {}, + "pending": [{"path": [], "label": "DeferQuery"}], + "hasNext": True, + }, { "incremental": [{"data": {"hero": {"id": "1"}}, "path": []}], "completed": [{"path": [], "label": "DeferQuery"}], @@ -606,7 +704,11 @@ async def can_defer_fragments_with_errors_on_the_top_level_query_field(): result = await complete(document, {"hero": {**hero, "name": Resolvers.bad}}) assert result == [ - {"data": {}, "hasNext": True}, + { + "data": {}, + "pending": [{"path": [], "label": "DeferQuery"}], + "hasNext": True, + }, { "incremental": [ { @@ -649,7 +751,14 @@ async def can_defer_a_fragment_within_an_already_deferred_fragment(): result = await complete(document) assert result == [ - {"data": {"hero": {}}, "hasNext": True}, + { + "data": {"hero": {}}, + "pending": [ + {"path": ["hero"], "label": "DeferTop"}, + {"path": ["hero"], "label": "DeferNested"}, + ], + "hasNext": True, + }, { "incremental": [ { @@ -693,7 +802,11 @@ async def can_defer_a_fragment_that_is_also_not_deferred_with_deferred_first(): result = await complete(document) assert result == [ - {"data": {"hero": {"name": "Luke"}}, "hasNext": True}, + { + "data": {"hero": {"name": "Luke"}}, + "pending": [{"path": ["hero"], "label": "DeferTop"}], + "hasNext": True, + }, { "completed": [{"path": ["hero"], "label": "DeferTop"}], "hasNext": False, @@ -718,7 +831,11 @@ async def can_defer_a_fragment_that_is_also_not_deferred_with_non_deferred_first result = await complete(document) assert result == [ - {"data": {"hero": {"name": "Luke"}}, "hasNext": True}, + { + "data": {"hero": {"name": "Luke"}}, + "pending": [{"path": ["hero"], "label": "DeferTop"}], + "hasNext": True, + }, { "completed": [{"path": ["hero"], "label": "DeferTop"}], "hasNext": False, @@ -742,7 +859,11 @@ async def can_defer_an_inline_fragment(): result = await complete(document) assert result == [ - {"data": {"hero": {"id": "1"}}, "hasNext": True}, + { + "data": {"hero": {"id": "1"}}, + "pending": [{"path": ["hero"], "label": "InlineDeferred"}], + "hasNext": True, + }, { "incremental": [{"data": {"name": "Luke"}, "path": ["hero"]}], "completed": [{"path": ["hero"], "label": "InlineDeferred"}], @@ -769,7 +890,7 @@ async def does_not_emit_empty_defer_fragments(): result = await complete(document) assert result == [ - {"data": {"hero": {}}, "hasNext": True}, + {"data": {"hero": {}}, "pending": [{"path": ["hero"]}], "hasNext": True}, { "completed": [{"path": ["hero"]}], "hasNext": False, @@ -797,6 +918,10 @@ async def separately_emits_defer_fragments_different_labels_varying_fields(): assert result == [ { "data": {"hero": {}}, + "pending": [ + {"path": ["hero"], "label": "DeferID"}, + {"path": ["hero"], "label": "DeferName"}, + ], "hasNext": True, }, { @@ -841,6 +966,10 @@ async def separately_emits_defer_fragments_different_labels_varying_subfields(): assert result == [ { "data": {}, + "pending": [ + {"path": [], "label": "DeferID"}, + {"path": [], "label": "DeferName"}, + ], "hasNext": True, }, { @@ -901,6 +1030,10 @@ async def resolve(value): assert result == [ { "data": {}, + "pending": [ + {"path": [], "label": "DeferID"}, + {"path": [], "label": "DeferName"}, + ], "hasNext": True, }, { @@ -949,6 +1082,10 @@ async def separately_emits_defer_fragments_var_subfields_same_prio_diff_level(): assert result == [ { "data": {"hero": {}}, + "pending": [ + {"path": [], "label": "DeferName"}, + {"path": ["hero"], "label": "DeferID"}, + ], "hasNext": True, }, { @@ -991,9 +1128,11 @@ async def separately_emits_nested_defer_frags_var_subfields_same_prio_diff_level assert result == [ { "data": {}, + "pending": [{"path": [], "label": "DeferName"}], "hasNext": True, }, { + "pending": [{"path": ["hero"], "label": "DeferID"}], "incremental": [ { "data": { @@ -1055,7 +1194,24 @@ async def can_deduplicate_multiple_defers_on_the_same_object(): result = await complete(document) assert result == [ - {"data": {"hero": {"friends": [{}, {}, {}]}}, "hasNext": True}, + { + "data": {"hero": {"friends": [{}, {}, {}]}}, + "pending": [ + {"path": ["hero", "friends", 0]}, + {"path": ["hero", "friends", 0]}, + {"path": ["hero", "friends", 0]}, + {"path": ["hero", "friends", 0]}, + {"path": ["hero", "friends", 1]}, + {"path": ["hero", "friends", 1]}, + {"path": ["hero", "friends", 1]}, + {"path": ["hero", "friends", 1]}, + {"path": ["hero", "friends", 2]}, + {"path": ["hero", "friends", 2]}, + {"path": ["hero", "friends", 2]}, + {"path": ["hero", "friends", 2]}, + ], + "hasNext": True, + }, { "incremental": [ { @@ -1139,6 +1295,7 @@ async def deduplicates_fields_present_in_the_initial_payload(): "anotherNestedObject": {"deeperObject": {"foo": "foo"}}, } }, + "pending": [{"path": ["hero"]}], "hasNext": True, }, { @@ -1182,9 +1339,11 @@ async def deduplicates_fields_present_in_a_parent_defer_payload(): assert result == [ { "data": {"hero": {}}, + "pending": [{"path": ["hero"]}], "hasNext": True, }, { + "pending": [{"path": ["hero", "nestedObject", "deeperObject"]}], "incremental": [ { "data": { @@ -1277,9 +1436,11 @@ async def deduplicates_fields_with_deferred_fragments_at_multiple_levels(): }, }, }, + "pending": [{"path": ["hero"]}], "hasNext": True, }, { + "pending": [{"path": ["hero", "nestedObject"]}], "incremental": [ { "data": {"bar": "bar"}, @@ -1290,6 +1451,7 @@ async def deduplicates_fields_with_deferred_fragments_at_multiple_levels(): "hasNext": True, }, { + "pending": [{"path": ["hero", "nestedObject", "deeperObject"]}], "incremental": [ { "data": {"baz": "baz"}, @@ -1346,9 +1508,14 @@ async def deduplicates_fields_from_deferred_fragments_branches_same_level(): assert result == [ { "data": {"hero": {"nestedObject": {"deeperObject": {}}}}, + "pending": [ + {"path": ["hero"]}, + {"path": ["hero", "nestedObject", "deeperObject"]}, + ], "hasNext": True, }, { + "pending": [{"path": ["hero", "nestedObject", "deeperObject"]}], "incremental": [ { "data": { @@ -1417,6 +1584,7 @@ async def deduplicates_fields_from_deferred_fragments_branches_multi_levels(): assert result == [ { "data": {"a": {"b": {"c": {"d": "d"}}}}, + "pending": [{"path": []}, {"path": ["a", "b"]}], "hasNext": True, }, { @@ -1470,6 +1638,7 @@ async def nulls_cross_defer_boundaries_null_first(): assert result == [ { "data": {"a": {}}, + "pending": [{"path": []}, {"path": ["a"]}], "hasNext": True, }, { @@ -1540,6 +1709,7 @@ async def nulls_cross_defer_boundaries_value_first(): assert result == [ { "data": {"a": {}}, + "pending": [{"path": []}, {"path": ["a"]}], "hasNext": True, }, { @@ -1613,6 +1783,7 @@ async def filters_a_payload_with_a_null_that_cannot_be_merged(): assert result == [ { "data": {"a": {}}, + "pending": [{"path": []}, {"path": ["a"]}], "hasNext": True, }, { @@ -1704,6 +1875,7 @@ async def cancels_deferred_fields_when_deferred_result_exhibits_null_bubbling(): assert result == [ { "data": {}, + "pending": [{"path": []}], "hasNext": True, }, { @@ -1757,6 +1929,7 @@ async def deduplicates_list_fields(): ] } }, + "pending": [{"path": ["hero"]}], "hasNext": True, }, { @@ -1793,6 +1966,7 @@ async def deduplicates_async_iterable_list_fields(): assert result == [ { "data": {"hero": {"friends": [{"name": "Han"}]}}, + "pending": [{"path": ["hero"]}], "hasNext": True, }, { @@ -1834,6 +2008,7 @@ async def resolve_friends(_info): assert result == [ { "data": {"hero": {"friends": []}}, + "pending": [{"path": ["hero"]}], "hasNext": True, }, { @@ -1872,6 +2047,7 @@ async def does_not_deduplicate_list_fields_with_non_overlapping_fields(): ] } }, + "pending": [{"path": ["hero"]}], "hasNext": True, }, { @@ -1918,6 +2094,7 @@ async def deduplicates_list_fields_that_return_empty_lists(): assert result == [ { "data": {"hero": {"friends": []}}, + "pending": [{"path": ["hero"]}], "hasNext": True, }, { @@ -1950,6 +2127,7 @@ async def deduplicates_null_object_fields(): assert result == [ { "data": {"hero": {"nestedObject": None}}, + "pending": [{"path": ["hero"]}], "hasNext": True, }, { @@ -1986,6 +2164,7 @@ async def resolve_nested_object(_info): assert result == [ { "data": {"hero": {"nestedObject": {"name": "foo"}}}, + "pending": [{"path": ["hero"]}], "hasNext": True, }, { @@ -2012,7 +2191,11 @@ async def handles_errors_thrown_in_deferred_fragments(): result = await complete(document, {"hero": {**hero, "name": Resolvers.bad}}) assert result == [ - {"data": {"hero": {"id": "1"}}, "hasNext": True}, + { + "data": {"hero": {"id": "1"}}, + "pending": [{"path": ["hero"]}], + "hasNext": True, + }, { "incremental": [ { @@ -2052,7 +2235,11 @@ async def handles_non_nullable_errors_thrown_in_deferred_fragments(): ) assert result == [ - {"data": {"hero": {"id": "1"}}, "hasNext": True}, + { + "data": {"hero": {"id": "1"}}, + "pending": [{"path": ["hero"]}], + "hasNext": True, + }, { "completed": [ { @@ -2122,7 +2309,11 @@ async def handles_async_non_nullable_errors_thrown_in_deferred_fragments(): ) assert result == [ - {"data": {"hero": {"id": "1"}}, "hasNext": True}, + { + "data": {"hero": {"id": "1"}}, + "pending": [{"path": ["hero"]}], + "hasNext": True, + }, { "completed": [ { @@ -2165,8 +2356,17 @@ async def returns_payloads_in_correct_order(): result = await complete(document, {"hero": {**hero, "name": Resolvers.slow}}) assert result == [ - {"data": {"hero": {"id": "1"}}, "hasNext": True}, { + "data": {"hero": {"id": "1"}}, + "pending": [{"path": ["hero"]}], + "hasNext": True, + }, + { + "pending": [ + {"path": ["hero", "friends", 0]}, + {"path": ["hero", "friends", 1]}, + {"path": ["hero", "friends", 2]}, + ], "incremental": [ { "data": {"name": "slow", "friends": [{}, {}, {}]}, @@ -2224,8 +2424,17 @@ async def returns_payloads_from_synchronous_data_in_correct_order(): result = await complete(document) assert result == [ - {"data": {"hero": {"id": "1"}}, "hasNext": True}, { + "data": {"hero": {"id": "1"}}, + "pending": [{"path": ["hero"]}], + "hasNext": True, + }, + { + "pending": [ + {"path": ["hero", "friends", 0]}, + {"path": ["hero", "friends", 1]}, + {"path": ["hero", "friends", 2]}, + ], "incremental": [ { "data": {"name": "Luke", "friends": [{}, {}, {}]}, diff --git a/tests/execution/test_execution_result.py b/tests/execution/test_execution_result.py index 162bd00d..96935d99 100644 --- a/tests/execution/test_execution_result.py +++ b/tests/execution/test_execution_result.py @@ -55,15 +55,15 @@ def compares_to_dict(): res = ExecutionResult(data, errors) assert res == {"data": data, "errors": errors} assert res == {"data": data, "errors": errors, "extensions": None} - assert res != {"data": data, "errors": None} - assert res != {"data": None, "errors": errors} + assert res == {"data": data, "errors": errors, "extensions": {}} + assert res != {"errors": errors} + assert res != {"data": data} assert res != {"data": data, "errors": errors, "extensions": extensions} res = ExecutionResult(data, errors, extensions) - assert res == {"data": data, "errors": errors} assert res == {"data": data, "errors": errors, "extensions": extensions} - assert res != {"data": data, "errors": None} - assert res != {"data": None, "errors": errors} - assert res != {"data": data, "errors": errors, "extensions": None} + assert res != {"errors": errors, "extensions": extensions} + assert res != {"data": data, "extensions": extensions} + assert res != {"data": data, "errors": errors} def compares_to_tuple(): res = ExecutionResult(data, errors) diff --git a/tests/execution/test_mutations.py b/tests/execution/test_mutations.py index f5030c88..987eba45 100644 --- a/tests/execution/test_mutations.py +++ b/tests/execution/test_mutations.py @@ -242,7 +242,11 @@ async def mutation_fields_with_defer_do_not_block_next_mutation(): patches.append(patch.formatted) assert patches == [ - {"data": {"first": {}, "second": {"theNumber": 2}}, "hasNext": True}, + { + "data": {"first": {}, "second": {"theNumber": 2}}, + "pending": [{"path": ["first"], "label": "defer-label"}], + "hasNext": True, + }, { "incremental": [ { @@ -313,7 +317,11 @@ async def mutation_with_defer_is_not_executed_serially(): patches.append(patch.formatted) assert patches == [ - {"data": {"second": {"theNumber": 2}}, "hasNext": True}, + { + "data": {"second": {"theNumber": 2}}, + "pending": [{"path": [], "label": "defer-label"}], + "hasNext": True, + }, { "incremental": [ { diff --git a/tests/execution/test_stream.py b/tests/execution/test_stream.py index 5454e826..4331eaa4 100644 --- a/tests/execution/test_stream.py +++ b/tests/execution/test_stream.py @@ -199,9 +199,9 @@ def can_compare_incremental_stream_result(): assert result != tuple(args.values())[:1] assert result != (["hello", "world"], []) assert result == args - assert result == dict(list(args.items())[:2]) - assert result == dict(list(args.items())[:3]) - assert result != dict(list(args.items())[:2] + [("path", ["foo", 2])]) + assert result != {**args, "items": ["hello", "foo"]} + assert result != {**args, "errors": []} + assert result != {**args, "path": ["foo", 2]} assert result != {**args, "extensions": {"baz": 1}} @pytest.mark.asyncio @@ -215,6 +215,7 @@ async def can_stream_a_list_field(): "data": { "scalarList": ["apple"], }, + "pending": [{"path": ["scalarList"]}], "hasNext": True, }, { @@ -239,6 +240,7 @@ async def can_use_default_value_of_initial_count(): "data": { "scalarList": [], }, + "pending": [{"path": ["scalarList"]}], "hasNext": True, }, { @@ -305,6 +307,7 @@ async def returns_label_from_stream_directive(): "data": { "scalarList": ["apple"], }, + "pending": [{"path": ["scalarList"], "label": "scalar-stream"}], "hasNext": True, }, { @@ -375,6 +378,7 @@ async def does_not_disable_stream_with_null_if_argument(): "data": { "scalarList": ["apple", "banana"], }, + "pending": [{"path": ["scalarList"]}], "hasNext": True, }, { @@ -407,6 +411,7 @@ async def can_stream_multi_dimensional_lists(): "data": { "scalarListList": [["apple", "apple", "apple"]], }, + "pending": [{"path": ["scalarListList"]}], "hasNext": True, }, { @@ -458,6 +463,7 @@ async def await_friend(f): {"name": "Han", "id": "2"}, ], }, + "pending": [{"path": ["friendList"]}], "hasNext": True, }, { @@ -495,6 +501,7 @@ async def await_friend(f): assert result == [ { "data": {"friendList": []}, + "pending": [{"path": ["friendList"]}], "hasNext": True, }, { @@ -562,6 +569,7 @@ async def get_id(f): {"name": "Han", "id": "2"}, ] }, + "pending": [{"path": ["friendList"]}], "hasNext": True, }, { @@ -612,6 +620,7 @@ async def await_friend(f, i): "path": ["friendList", 1], } ], + "pending": [{"path": ["friendList"]}], "hasNext": True, }, { @@ -655,6 +664,7 @@ async def await_friend(f, i): assert result == [ { "data": {"friendList": [{"name": "Luke", "id": "1"}]}, + "pending": [{"path": ["friendList"]}], "hasNext": True, }, { @@ -706,6 +716,7 @@ async def friend_list(_info): assert result == [ { "data": {"friendList": []}, + "pending": [{"path": ["friendList"]}], "hasNext": True, }, { @@ -767,6 +778,7 @@ async def friend_list(_info): {"name": "Han", "id": "2"}, ] }, + "pending": [{"path": ["friendList"]}], "hasNext": True, }, { @@ -840,6 +852,7 @@ async def friend_list(_info): {"name": "Han", "id": "2"}, ] }, + "pending": [{"path": ["friendList"]}], "hasNext": True, }, }, @@ -914,6 +927,7 @@ async def friend_list(_info): "data": { "friendList": [{"name": "Luke", "id": "1"}], }, + "pending": [{"path": ["friendList"]}], "hasNext": True, }, { @@ -953,6 +967,7 @@ async def handles_null_for_non_null_list_items_after_initial_count_is_reached(): "data": { "nonNullFriendList": [{"name": "Luke"}], }, + "pending": [{"path": ["nonNullFriendList"]}], "hasNext": True, }, { @@ -998,6 +1013,7 @@ async def friend_list(_info): "data": { "nonNullFriendList": [{"name": "Luke"}], }, + "pending": [{"path": ["nonNullFriendList"]}], "hasNext": True, }, { @@ -1037,6 +1053,7 @@ async def scalar_list(_info): "data": { "scalarList": ["Luke"], }, + "pending": [{"path": ["scalarList"]}], "hasNext": True, }, { @@ -1090,6 +1107,7 @@ def get_friends(_info): "data": { "friendList": [{"nonNullName": "Luke"}], }, + "pending": [{"path": ["friendList"]}], "hasNext": True, }, { @@ -1151,6 +1169,7 @@ def get_friends(_info): "data": { "friendList": [{"nonNullName": "Luke"}], }, + "pending": [{"path": ["friendList"]}], "hasNext": True, }, { @@ -1213,6 +1232,7 @@ def get_friends(_info): "data": { "nonNullFriendList": [{"nonNullName": "Luke"}], }, + "pending": [{"path": ["nonNullFriendList"]}], "hasNext": True, }, { @@ -1263,6 +1283,7 @@ def get_friends(_info): "data": { "nonNullFriendList": [{"nonNullName": "Luke"}], }, + "pending": [{"path": ["nonNullFriendList"]}], "hasNext": True, }, { @@ -1315,6 +1336,7 @@ async def get_friends(_info): "data": { "friendList": [{"nonNullName": "Luke"}], }, + "pending": [{"path": ["friendList"]}], "hasNext": True, }, { @@ -1381,6 +1403,7 @@ async def get_friends(_info): "data": { "nonNullFriendList": [{"nonNullName": "Luke"}], }, + "pending": [{"path": ["nonNullFriendList"]}], "hasNext": True, }, { @@ -1443,6 +1466,7 @@ async def __anext__(self): "data": { "nonNullFriendList": [{"nonNullName": "Luke"}], }, + "pending": [{"path": ["nonNullFriendList"]}], "hasNext": True, }, { @@ -1512,6 +1536,7 @@ async def aclose(self): "data": { "nonNullFriendList": [{"nonNullName": "Luke"}], }, + "pending": [{"path": ["nonNullFriendList"]}], "hasNext": True, }, { @@ -1666,6 +1691,10 @@ async def friend_list(_info): "otherNestedObject": {}, "nestedObject": {"nestedFriendList": []}, }, + "pending": [ + {"path": ["otherNestedObject"]}, + {"path": ["nestedObject", "nestedFriendList"]}, + ], "hasNext": True, }, { @@ -1738,6 +1767,7 @@ async def friend_list(_info): "data": { "nestedObject": {}, }, + "pending": [{"path": ["nestedObject"]}], "hasNext": True, }, { @@ -1801,6 +1831,7 @@ async def friend_list(_info): "data": { "friendList": [], }, + "pending": [{"path": ["friendList"]}], "hasNext": True, }, { @@ -1875,7 +1906,11 @@ async def iterable(_info): iterator = execute_result.subsequent_results result1 = execute_result.initial_result - assert result1 == {"data": {"nestedObject": {}}, "hasNext": True} + assert result1 == { + "data": {"nestedObject": {}}, + "pending": [{"path": ["nestedObject"]}], + "hasNext": True, + } assert not finished @@ -1944,6 +1979,7 @@ async def get_friends(_info): "data": { "friendList": [{"id": "1", "name": "Luke"}], }, + "pending": [{"path": ["friendList"]}], "hasNext": True, }, { @@ -2012,6 +2048,10 @@ async def get_nested_friend_list(_info): "nestedFriendList": [], }, }, + "pending": [ + {"path": ["nestedObject"]}, + {"path": ["nestedObject", "nestedFriendList"]}, + ], "hasNext": True, }, { @@ -2082,11 +2122,16 @@ async def get_friends(_info): iterator = execute_result.subsequent_results result1 = execute_result.initial_result - assert result1 == {"data": {"nestedObject": {}}, "hasNext": True} + assert result1 == { + "data": {"nestedObject": {}}, + "pending": [{"path": ["nestedObject"]}], + "hasNext": True, + } resolve_slow_field.set() result2 = await anext(iterator) assert result2.formatted == { + "pending": [{"path": ["nestedObject", "nestedFriendList"]}], "incremental": [ { "data": {"scalarField": "slow", "nestedFriendList": []}, @@ -2166,11 +2211,19 @@ async def get_friends(_info): iterator = execute_result.subsequent_results result1 = execute_result.initial_result - assert result1 == {"data": {"friendList": [{"id": "1"}]}, "hasNext": True} + assert result1 == { + "data": {"friendList": [{"id": "1"}]}, + "pending": [ + {"path": ["friendList", 0], "label": "DeferName"}, + {"path": ["friendList"], "label": "stream-label"}, + ], + "hasNext": True, + } resolve_iterable.set() result2 = await anext(iterator) assert result2.formatted == { + "pending": [{"path": ["friendList", 1], "label": "DeferName"}], "incremental": [ { "data": {"name": "Luke"}, @@ -2251,11 +2304,19 @@ async def get_friends(_info): iterator = execute_result.subsequent_results result1 = execute_result.initial_result - assert result1 == {"data": {"friendList": [{"id": "1"}]}, "hasNext": True} + assert result1 == { + "data": {"friendList": [{"id": "1"}]}, + "pending": [ + {"path": ["friendList", 0], "label": "DeferName"}, + {"path": ["friendList"], "label": "stream-label"}, + ], + "hasNext": True, + } resolve_slow_field.set() result2 = await anext(iterator) assert result2.formatted == { + "pending": [{"path": ["friendList", 1], "label": "DeferName"}], "incremental": [ { "data": {"name": "Luke"}, @@ -2322,7 +2383,11 @@ async def iterable(_info): iterator = execute_result.subsequent_results result1 = execute_result.initial_result - assert result1 == {"data": {"friendList": [{"id": "1"}]}, "hasNext": True} + assert result1 == { + "data": {"friendList": [{"id": "1"}]}, + "pending": [{"path": ["friendList", 0]}, {"path": ["friendList"]}], + "hasNext": True, + } await iterator.aclose() with pytest.raises(StopAsyncIteration): @@ -2369,6 +2434,7 @@ async def __anext__(self): result1 = execute_result.initial_result assert result1 == { "data": {"friendList": [{"id": "1", "name": "Luke"}]}, + "pending": [{"path": ["friendList"]}], "hasNext": True, } @@ -2408,7 +2474,11 @@ async def iterable(_info): iterator = execute_result.subsequent_results result1 = execute_result.initial_result - assert result1 == {"data": {"friendList": [{"id": "1"}]}, "hasNext": True} + assert result1 == { + "data": {"friendList": [{"id": "1"}]}, + "pending": [{"path": ["friendList", 0]}, {"path": ["friendList"]}], + "hasNext": True, + } with pytest.raises(RuntimeError, match="bad"): await iterator.athrow(RuntimeError("bad"))