Repository: graphql-python/graphene
Pull request #1506 (2023-05-29 18:07:14)

Allow the user to change InputObjectType's default value on non-specified inputs to a sentinel value
This PR aims to remove the ambiguity that happens with `InputObjectType`s when accessing optional fields using the `input.<attribute>` "dot access" syntax.

### Current behavior:

If an optional input field is defined but has not been specified in an incoming `InputObjectType`, it comes back as a `None`

This is ambiguous, since the user could also have set that specific field to `None`, meaning we wouldn't be able to tell between an explicit setting to `None` versus the field not being set at all.

### Proposed behavior in this PR:

Introduce a non-breaking, opt-in method to allow the users to change this default value getter behavior in InputObjectTypes to any value that they want to. Graphene already provides such concept in the form of the `graphl.Undefined` value, which is a good example of a value that the user could use to differentiate between an optional input being explicitly set to `None` versus not being set at all.

The proposed API is a function called `set_input_object_type_default_value()` that allows the user to change the sentinel value that will be returned in optional inputs (it should be called early in the code, before any `InputObjectType`s are defined). The default value remains `None`, aligning with the current behavior, so as not to break existing code that relies on the current assumption.

#### _(Next steps and parting thoughts)_
Moving forward, I believe it'd be best to discuss around introducing a well documented breaking change that fixes this behavior altogether, eliminating such possibility. But the current PR should allow us to keep making progress while bigger fish are being fried :fish: :cook: 

Thanks!

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/graphql-python/graphene/pull/1506?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=graphql-python) Report
Patch coverage: **`100.00`**% and no project coverage change.
> Comparison is base [(`8ede21e`)](https://app.codecov.io/gh/graphql-python/graphene/commit/8ede21e06381c096589c424960a6cfaca304badb?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=graphql-python) 96.00% compared to head [(`37a5759`)](https://app.codecov.io/gh/graphql-python/graphene/pull/1506?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=graphql-python) 96.00%.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #1506   +/-   ##
=======================================
  Coverage   96.00%   96.00%           
=======================================
  Files          51       51           
  Lines        1750     1753    +3     
=======================================
+ Hits         1680     1683    +3     
  Misses         70       70           
```


| [Impacted Files](https://app.codecov.io/gh/graphql-python/graphene/pull/1506?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=graphql-python) | Coverage Δ | |
|---|---|---|
| [graphene/types/inputobjecttype.py](https://app.codecov.io/gh/graphql-python/graphene/pull/1506?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=graphql-python#diff-Z3JhcGhlbmUvdHlwZXMvaW5wdXRvYmplY3R0eXBlLnB5) | `97.50% <100.00%> (+0.20%)` | :arrow_up: |
| [graphene/validation/depth\_limit.py](https://app.codecov.io/gh/graphql-python/graphene/pull/1506?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=graphql-python#diff-Z3JhcGhlbmUvdmFsaWRhdGlvbi9kZXB0aF9saW1pdC5weQ==) | `96.96% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/graphql-python/graphene/pull/1506?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=graphql-python).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=graphql-python).


Patch:
diff --git a/graphene/types/inputobjecttype.py b/graphene/types/inputobjecttype.py
index 5d27851..fdf38ba 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.<attribute> 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/validation/depth_limit.py b/graphene/validation/depth_limit.py
index b4599e6..e0f2866 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 = {}
 


Test patch:
diff --git a/graphene/types/tests/conftest.py b/graphene/types/tests/conftest.py
new file mode 100644
index 0000000..43f7d72
--- /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 0fb7e39..0d7bcf8 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 55b1706..55665b6 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"""