From fcbfffa6f8b4ebaeec7322f1ca72ab1e723de9a9 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Fri, 9 Oct 2026 22:56:35 +0200 Subject: [PATCH] Memoize coerced default values on the input value Like GraphQL.js, memoize on the argument or input field instead of the shared default, so that a default used with different types is coerced only once per type and no type is kept alive by another schema. This also restores the slot name of GraphQLDefaultInput that is needed for unpickling schemas pickled with 3.3.0. --- src/graphql/type/definition.py | 16 +++++++++--- src/graphql/utilities/coerce_input_value.py | 26 +++++++++++------- tests/utilities/test_coerce_input_value.py | 29 ++++++++++++++++++++- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/src/graphql/type/definition.py b/src/graphql/type/definition.py index 5c24a861..37254d73 100644 --- a/src/graphql/type/definition.py +++ b/src/graphql/type/definition.py @@ -1254,16 +1254,16 @@ class GraphQLDefaultInput: # noqa: PLW1641 literal: ConstValueNode | None """GraphQL literal default value, or None if a runtime value is provided instead.""" - __slots__ = "_memoized_coercion", "literal", "value" + __slots__ = "_memoized_coerced_value", "literal", "value" def __init__( self, value: Any = Undefined, literal: ConstValueNode | None = None ) -> None: self.value = value self.literal = literal - # Used to memoize the result of coercing the default value, together with - # the type it was coerced to (see coerce_default_value() in the utilities). - self._memoized_coercion: tuple[GraphQLInputType | None, Any] = None, Undefined + # Used to memoize the result of coercing the default value of a variable + # signature (see coerce_default_value() in the utilities). + self._memoized_coerced_value: Any = Undefined def __eq__(self, other: object) -> bool: return self is other or ( @@ -1352,6 +1352,10 @@ class GraphQLArgument: # noqa: PLW1641 ast_node: InputValueDefinitionNode | None """AST node from which this schema element was built, if available.""" + # Used to memoize the result of coercing the default value + # (see coerce_default_value() in the utilities). + _memoized_coerced_value: Any = Undefined + def __init__( self, type_: GraphQLInputType, @@ -3326,6 +3330,10 @@ class GraphQLInputField: # noqa: PLW1641 ast_node: InputValueDefinitionNode | None """AST node from which this schema element was built, if available.""" + # Used to memoize the result of coercing the default value + # (see coerce_default_value() in the utilities). + _memoized_coerced_value: Any = Undefined + def __init__( self, type_: GraphQLInputType, diff --git a/src/graphql/utilities/coerce_input_value.py b/src/graphql/utilities/coerce_input_value.py index 07ac680f..afb66d96 100644 --- a/src/graphql/utilities/coerce_input_value.py +++ b/src/graphql/utilities/coerce_input_value.py @@ -18,6 +18,8 @@ is_iterable, ) from ..type import ( + GraphQLArgument, + GraphQLInputField, GraphQLInputType, assert_leaf_type, is_input_object_type, @@ -31,7 +33,7 @@ from ..execution.get_variable_signature import GraphQLVariableSignature from ..execution.values import FragmentVariableValues, VariableValues from ..language import ConstValueNode - from ..type import GraphQLArgument, GraphQLInputField + from ..type import GraphQLDefaultInput __all__ = ["coerce_default_value", "coerce_input_literal", "coerce_input_value"] @@ -351,16 +353,20 @@ def coerce_default_value( :meta private: """ - # The external default value is coerced; the result is memoized in a hidden - # field on the GraphQLDefaultInput object. (Contrary to GraphQL.js, which - # memoizes on the input value itself, this also works for the immutable - # variable signatures that reuse this function for fragment arguments.) - # It is only reused for the same type, as a default can be used with - # different types, e.g. after extend_schema() replaced the type. + # The external default value is coerced; like in GraphQL.js, the result is + # memoized in a hidden field on the input value itself, since the same default + # can be used with different types, e.g. after extend_schema() replaced the + # type. The immutable variable signatures that reuse this function for fragment + # arguments memoize it on their default instead, which is not shared. default_input = input_value.default if default_input is not None: - coerced_type, coerced_value = default_input._memoized_coercion # noqa: SLF001 - if coerced_type is not input_value.type: + memo: GraphQLArgument | GraphQLInputField | GraphQLDefaultInput = ( + input_value + if isinstance(input_value, (GraphQLArgument, GraphQLInputField)) + else default_input + ) + coerced_value = memo._memoized_coerced_value # noqa: SLF001 + if coerced_value is Undefined: coerced_value = ( coerce_input_literal(default_input.literal, input_value.type) if default_input.literal is not None @@ -377,7 +383,7 @@ def coerce_default_value( f" to be valid, found: {found}." ) raise TypeError(msg) - default_input._memoized_coercion = input_value.type, coerced_value # noqa: SLF001 + memo._memoized_coerced_value = coerced_value # noqa: SLF001 return coerced_value # The deprecated internal default value is used as is. diff --git a/tests/utilities/test_coerce_input_value.py b/tests/utilities/test_coerce_input_value.py index 97f329fe..a91ba5d3 100644 --- a/tests/utilities/test_coerce_input_value.py +++ b/tests/utilities/test_coerce_input_value.py @@ -717,7 +717,7 @@ def coerce_input_value(value): assert coerce_default_value(input_value) == "hello" assert coerce_input_value_calls == ["hello"] - def memoizes_coercion_per_type(): + def coerces_shared_default_per_type(): color_enum = GraphQLEnumType("Color", {"RED": 0, "BLUE": 1}) shared_default = GraphQLDefaultInput("RED") @@ -727,3 +727,30 @@ def memoizes_coercion_per_type(): assert coerce_default_value(color_value) == 0 assert coerce_default_value(string_value) == "RED" assert coerce_default_value(color_value) == 0 + + def memoizes_coercion_of_shared_default_per_input_value(): + coerce_input_value_calls: list[Any] = [] + + def coerce_input_value(value): + coerce_input_value_calls.append(value) + return value + + spy_scalar = GraphQLScalarType( + "SpyScalar", coerce_input_value=coerce_input_value + ) + other_spy_scalar = GraphQLScalarType( + "SpyScalar", coerce_input_value=coerce_input_value + ) + + shared_default = GraphQLDefaultInput(literal=StringValueNode(value="hello")) + + input_value = GraphQLInputField(spy_scalar, default=shared_default) + other_input_value = GraphQLInputField(other_spy_scalar, default=shared_default) + + assert coerce_default_value(input_value) == "hello" + assert coerce_default_value(other_input_value) == "hello" + + # Call a second time + assert coerce_default_value(input_value) == "hello" + assert coerce_default_value(other_input_value) == "hello" + assert coerce_input_value_calls == ["hello", "hello"]