From 2da8e9db5cd6527ca740914ce0095e5004054dfd Mon Sep 17 00:00:00 2001 From: Cadu Date: Sun, 4 Jun 2023 18:01:05 -0300 Subject: [PATCH] feat: Enable use of Undefined in InputObjectTypes (#1506) * Changed InputObjectType's default builder-from-dict argument to be `Undefined` instead of `None`, removing ambiguity of undefined optional inputs using dot notation access syntax. * Move `set_default_input_object_type_to_undefined()` fixture into conftest.py for sharing it between multiple test files. --- graphene/types/inputobjecttype.py | 27 ++++++++++++++++- graphene/types/tests/conftest.py | 12 ++++++++ graphene/types/tests/test_inputobjecttype.py | 31 ++++++++++++++++++++ graphene/types/tests/test_type_map.py | 14 ++++++++- graphene/validation/depth_limit.py | 6 ++-- 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 graphene/types/tests/conftest.py diff --git a/graphene/types/inputobjecttype.py b/graphene/types/inputobjecttype.py index 5d278510..fdf38ba0 100644 --- a/graphene/types/inputobjecttype.py +++ b/graphene/types/inputobjecttype.py @@ -14,6 +14,31 @@ class InputObjectTypeOptions(BaseOptions): container = None # type: InputObjectTypeContainer +# Currently in Graphene, we get a `None` whenever we access an (optional) field that was not set in an InputObjectType +# using the InputObjectType. dot access syntax. This is ambiguous, because in this current (Graphene +# historical) arrangement, we cannot distinguish between a field not being set and a field being set to None. +# At the same time, we shouldn't break existing code that expects a `None` when accessing a field that was not set. +_INPUT_OBJECT_TYPE_DEFAULT_VALUE = None + +# To mitigate this, we provide the function `set_input_object_type_default_value` to allow users to change the default +# value returned in non-specified fields in InputObjectType to another meaningful sentinel value (e.g. Undefined) +# if they want to. This way, we can keep code that expects a `None` working while we figure out a better solution (or +# a well-documented breaking change) for this issue. + + +def set_input_object_type_default_value(default_value): + """ + Change the sentinel value returned by non-specified fields in an InputObjectType + Useful to differentiate between a field not being set and a field being set to None by using a sentinel value + (e.g. Undefined is a good sentinel value for this purpose) + + This function should be called at the beginning of the app or in some other place where it is guaranteed to + be called before any InputObjectType is defined. + """ + global _INPUT_OBJECT_TYPE_DEFAULT_VALUE + _INPUT_OBJECT_TYPE_DEFAULT_VALUE = default_value + + class InputObjectTypeContainer(dict, BaseType): # type: ignore class Meta: abstract = True @@ -21,7 +46,7 @@ class InputObjectTypeContainer(dict, BaseType): # type: ignore def __init__(self, *args, **kwargs): dict.__init__(self, *args, **kwargs) for key in self._meta.fields: - setattr(self, key, self.get(key, None)) + setattr(self, key, self.get(key, _INPUT_OBJECT_TYPE_DEFAULT_VALUE)) def __init_subclass__(cls, *args, **kwargs): pass diff --git a/graphene/types/tests/conftest.py b/graphene/types/tests/conftest.py new file mode 100644 index 00000000..43f7d726 --- /dev/null +++ b/graphene/types/tests/conftest.py @@ -0,0 +1,12 @@ +import pytest +from graphql import Undefined + +from graphene.types.inputobjecttype import set_input_object_type_default_value + + +@pytest.fixture() +def set_default_input_object_type_to_undefined(): + """This fixture is used to change the default value of optional inputs in InputObjectTypes for specific tests""" + set_input_object_type_default_value(Undefined) + yield + set_input_object_type_default_value(None) diff --git a/graphene/types/tests/test_inputobjecttype.py b/graphene/types/tests/test_inputobjecttype.py index 0fb7e394..0d7bcf80 100644 --- a/graphene/types/tests/test_inputobjecttype.py +++ b/graphene/types/tests/test_inputobjecttype.py @@ -1,3 +1,5 @@ +from graphql import Undefined + from ..argument import Argument from ..field import Field from ..inputfield import InputField @@ -6,6 +8,7 @@ from ..objecttype import ObjectType from ..scalars import Boolean, String from ..schema import Schema from ..unmountedtype import UnmountedType +from ... import NonNull class MyType: @@ -136,3 +139,31 @@ def test_inputobjecttype_of_input(): assert not result.errors assert result.data == {"isChild": True} + + +def test_inputobjecttype_default_input_as_undefined( + set_default_input_object_type_to_undefined, +): + class TestUndefinedInput(InputObjectType): + required_field = String(required=True) + optional_field = String() + + class Query(ObjectType): + undefined_optionals_work = Field(NonNull(Boolean), input=TestUndefinedInput()) + + def resolve_undefined_optionals_work(self, info, input: TestUndefinedInput): + # Confirm that optional_field comes as Undefined + return ( + input.required_field == "required" and input.optional_field is Undefined + ) + + schema = Schema(query=Query) + result = schema.execute( + """query basequery { + undefinedOptionalsWork(input: {requiredField: "required"}) + } + """ + ) + + assert not result.errors + assert result.data == {"undefinedOptionalsWork": True} diff --git a/graphene/types/tests/test_type_map.py b/graphene/types/tests/test_type_map.py index 55b1706e..55665b6b 100644 --- a/graphene/types/tests/test_type_map.py +++ b/graphene/types/tests/test_type_map.py @@ -20,8 +20,8 @@ from ..inputobjecttype import InputObjectType from ..interface import Interface from ..objecttype import ObjectType from ..scalars import Int, String -from ..structures import List, NonNull from ..schema import Schema +from ..structures import List, NonNull def create_type_map(types, auto_camelcase=True): @@ -227,6 +227,18 @@ def test_inputobject(): assert foo_field.description == "Field description" +def test_inputobject_undefined(set_default_input_object_type_to_undefined): + class OtherObjectType(InputObjectType): + optional_field = String() + + type_map = create_type_map([OtherObjectType]) + assert "OtherObjectType" in type_map + graphql_type = type_map["OtherObjectType"] + + container = graphql_type.out_type({}) + assert container.optional_field is Undefined + + def test_objecttype_camelcase(): class MyObjectType(ObjectType): """Description""" diff --git a/graphene/validation/depth_limit.py b/graphene/validation/depth_limit.py index b4599e66..e0f28663 100644 --- a/graphene/validation/depth_limit.py +++ b/graphene/validation/depth_limit.py @@ -30,7 +30,7 @@ try: except ImportError: # backwards compatibility for v3.6 from typing import Pattern -from typing import Callable, Dict, List, Optional, Union +from typing import Callable, Dict, List, Optional, Union, Tuple from graphql import GraphQLError from graphql.validation import ValidationContext, ValidationRule @@ -82,7 +82,7 @@ def depth_limit_validator( def get_fragments( - definitions: List[DefinitionNode], + definitions: Tuple[DefinitionNode, ...], ) -> Dict[str, FragmentDefinitionNode]: fragments = {} for definition in definitions: @@ -94,7 +94,7 @@ def get_fragments( # This will actually get both queries and mutations. # We can basically treat those the same def get_queries_and_mutations( - definitions: List[DefinitionNode], + definitions: Tuple[DefinitionNode, ...], ) -> Dict[str, OperationDefinitionNode]: operations = {}