Repository: strawberry-graphql/strawberry

Issue:
Use sentinel value for input parameters that aren't sent by the clients
When using input types with optional fields we cannot differentiate by fields that have sent as null and fields that haven't been sent at all.

So I think we should use a sentinel value that tells the field is unset, and also behaves as falsy:

```python
class _Unset:
    def __bool__(self): return False

UNSET = _Unset()

# this utility might be useful, so we don't have to use an internal representation
def is_unset(value: Any):
    return value is UNSET
```

then we can use this class when instantiating the input types for a resolver:)


Discussion:
I would like to take this up @patrick91 but I'm still wondering where to get started.

@Rwothoromo awesome! 

So, I think the place where we should do this is here: 
https://github.com/strawberry-graphql/strawberry/blob/master/strawberry/utils/arguments.py#L55-L66

we use this function here: https://github.com/strawberry-graphql/strawberry/blob/c1f9bec4c2605a3526a6a4c9678ac6e02bd36196/strawberry/field.py#L223

I'd start by adding a test here:
https://github.com/strawberry-graphql/strawberry/blob/d51b81ab87a1e9bc1038dac5da218fa79d15340f/tests/utils/test_arguments_converter.py

and then updating the code in arguments.py to make it work, the test could look something like this:

```python
def test_usees_unset_for_optional_types_when_nothing_is_passed():
    @strawberry.input
    class Number:
        value: int

    @strawberry.input
    class Input:
        numbers: typing.Optional[Number]

    args = {"input": {}}

    annotations = {"input": Input}

    assert convert_args(args, annotations) == {
        "input": UNSET
    }
```
Thank you @patrick91 . I'll take a look and let you know if I get stuck.

Resolving patch:
diff --git a/strawberry/utils/arguments.py b/strawberry/utils/arguments.py
--- a/strawberry/utils/arguments.py
+++ b/strawberry/utils/arguments.py
@@ -1,19 +1,49 @@
 import enum
+import typing
 from dataclasses import is_dataclass
 from datetime import date, datetime, time
 
 from ..exceptions import UnsupportedTypeError
-from .str_converters import to_camel_case, to_snake_case
+from .str_converters import to_camel_case
 from .typing import get_list_annotation, get_optional_annotation, is_list, is_optional
 
 
 SCALAR_TYPES = [int, str, float, bytes, bool, datetime, date, time]
 
 
-def _to_type(value, annotation):
+class _Unset:
+    def __str__(self):
+        return ""
+
+    def __bool__(self):
+        return False
+
+
+UNSET = _Unset()
+
+
+def is_unset(value: typing.Any) -> bool:
+    return value is UNSET
+
+
+def convert_args(
+    value: typing.Union[typing.Dict[str, typing.Any], typing.Any],
+    annotation: typing.Union[typing.Dict[str, typing.Type], typing.Type],
+):
+    """Converts a nested dictionary to a dictionary of actual types.
+
+    It deals with conversion of input types to proper dataclasses and
+    also uses a sentinel value for unset values."""
+
+    if annotation == {}:
+        return value
+
     if value is None:
         return None
 
+    if is_unset(value):
+        return value
+
     if is_optional(annotation):
         annotation = get_optional_annotation(annotation)
 
@@ -24,19 +54,27 @@ def _to_type(value, annotation):
     # Convert Enum fields to instances using the value. This is safe
     # because graphql-core has already validated the input.
     if isinstance(annotation, enum.EnumMeta):
-        return annotation(value)
+        return annotation(value)  # type: ignore
 
     if is_list(annotation):
         annotation = get_list_annotation(annotation)
 
-        return [_to_type(x, annotation) for x in value]
+        return [convert_args(x, annotation) for x in value]
+
+    fields = None
 
-    if is_dataclass(annotation):
-        fields = annotation.__dataclass_fields__
+    # we receive dicts when converting resolvers arguments to
+    # actual types
+    if isinstance(annotation, dict):
+        fields = annotation.items()
 
+    elif is_dataclass(annotation):
+        fields = annotation.__dataclass_fields__.items()
+
+    if fields:
         kwargs = {}
 
-        for name, field in fields.items():
+        for name, field in fields:
             dict_name = name
 
             if hasattr(field, "field_name") and field.field_name:
@@ -44,22 +82,19 @@ def _to_type(value, annotation):
             else:
                 dict_name = to_camel_case(name)
 
-            kwargs[name] = _to_type(value.get(dict_name), field.type)
-
-        return annotation(**kwargs)
-
-    raise UnsupportedTypeError(annotation)
-
-
-def convert_args(args, annotations):
-    """Converts a nested dictionary to a dictionary of strawberry input types."""
+            # dataclasses field have a .type attribute
+            if hasattr(field, "type"):
+                field_type = field.type
+            # meanwhile when using dicts the value of the field is
+            # the actual type, for example in: { 'name': str }
+            else:
+                field_type = field
 
-    converted_args = {}
+            kwargs[name] = convert_args(value.get(dict_name, UNSET), field_type)
 
-    for key, value in args.items():
-        key = to_snake_case(key)
-        annotation = annotations[key]
+        if is_dataclass(annotation):
+            return annotation(**kwargs)  # type: ignore
 
-        converted_args[key] = _to_type(value, annotation)
+        return kwargs
 
-    return converted_args
+    raise UnsupportedTypeError(annotation)


Test patch:
diff --git a/tests/test_schema.py b/tests/test_schema.py
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -7,6 +7,7 @@
 
 import strawberry
 from graphql import DirectiveLocation, graphql, graphql_sync
+from strawberry.utils.arguments import is_unset
 
 
 def test_init_var():
@@ -197,6 +198,116 @@ def say(self, info, input: SayInput) -> str:
     assert result.data["say"] == "Hello Patrick of 10 years old!"
 
 
+def test_unset_types():
+    @strawberry.type
+    class Query:
+        hello: str = "Hello"
+
+    @strawberry.input
+    class InputExample:
+        name: str
+        age: typing.Optional[int]
+
+    @strawberry.type
+    class Mutation:
+        @strawberry.mutation
+        def say(self, info, name: typing.Optional[str]) -> str:
+            if is_unset(name):
+                return "Name is unset"
+
+            return f"Hello {name}!"
+
+        @strawberry.mutation
+        def say_age(self, info, input: InputExample) -> str:
+            age = "unset" if is_unset(input.age) else input.age
+
+            return f"Hello {input.name} of age {age}!"
+
+    schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+    query = 'mutation { say sayAge(input: { name: "P"}) }'
+
+    result = graphql_sync(schema, query)
+
+    assert not result.errors
+    assert result.data["say"] == "Name is unset"
+    assert result.data["sayAge"] == "Hello P of age unset!"
+
+
+def test_unset_types_name_with_underscore():
+    @strawberry.type
+    class Query:
+        hello: str = "Hello"
+
+    @strawberry.input
+    class InputExample:
+        first_name: str
+        age: typing.Optional[str]
+
+    @strawberry.type
+    class Mutation:
+        @strawberry.mutation
+        def say(self, info, first_name: typing.Optional[str]) -> str:
+            if is_unset(first_name):
+                return "Name is unset"
+
+            if first_name == "":
+                return "Hello Empty!"
+
+            return f"Hello {first_name}!"
+
+        @strawberry.mutation
+        def say_age(self, info, input: InputExample) -> str:
+            age = "unset" if is_unset(input.age) else input.age
+            age = "empty" if age == "" else age
+
+            return f"Hello {input.first_name} of age {age}!"
+
+    schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+    query = """mutation {
+        one: say
+        two: say(firstName: "Patrick")
+        three: say(firstName: "")
+        empty: sayAge(input: { firstName: "Patrick", age: "" })
+        null: sayAge(input: { firstName: "Patrick", age: null })
+        sayAge(input: { firstName: "Patrick" })
+    }"""
+
+    result = graphql_sync(schema, query)
+
+    assert not result.errors
+    assert result.data["one"] == "Name is unset"
+    assert result.data["two"] == "Hello Patrick!"
+    assert result.data["three"] == "Hello Empty!"
+    assert result.data["empty"] == "Hello Patrick of age empty!"
+    assert result.data["null"] == "Hello Patrick of age None!"
+    assert result.data["sayAge"] == "Hello Patrick of age unset!"
+
+
+def test_unset_types_stringify_empty():
+    @strawberry.type
+    class Query:
+        hello: str = "Hello"
+
+    @strawberry.type
+    class Mutation:
+        @strawberry.mutation
+        def say(self, info, first_name: typing.Optional[str]) -> str:
+            return f"Hello {first_name}!"
+
+    schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+    query = """mutation {
+        say
+    }"""
+
+    result = graphql_sync(schema, query)
+
+    assert not result.errors
+    assert result.data["say"] == "Hello !"
+
+
 def test_does_camel_case_conversion():
     @strawberry.type
     class Query:
diff --git a/tests/utils/test_arguments_converter.py b/tests/utils/test_arguments_converter.py
--- a/tests/utils/test_arguments_converter.py
+++ b/tests/utils/test_arguments_converter.py
@@ -2,7 +2,7 @@
 from enum import Enum
 
 import strawberry
-from strawberry.utils.arguments import convert_args
+from strawberry.utils.arguments import UNSET, convert_args
 
 
 def test_simple_types():
@@ -20,9 +20,9 @@ def test_simple_types():
 
 def test_list():
     args = {
-        "integer_list": [1, 2],
-        "string_list": ["abc", "cde"],
-        "float_list": [1.2, 2.3],
+        "integerList": [1, 2],
+        "stringList": ["abc", "cde"],
+        "floatList": [1.2, 2.3],
     }
 
     annotations = {
@@ -74,7 +74,7 @@ def test_list_of_input_types():
     class MyInput:
         abc: str
 
-    args = {"input_list": [{"abc": "example"}]}
+    args = {"inputList": [{"abc": "example"}]}
 
     annotations = {"input_list": typing.List[MyInput]}
 
@@ -86,7 +86,7 @@ def test_optional_list_of_input_types():
     class MyInput:
         abc: str
 
-    args = {"input_list": [{"abc": "example"}]}
+    args = {"inputList": [{"abc": "example"}]}
 
     annotations = {"input_list": typing.Optional[typing.List[MyInput]]}
 
@@ -171,3 +171,28 @@ class Input:
     assert convert_args(args, annotations) == {
         "input": Input(numbers=[Number(1), Number(2)])
     }
+
+
+def test_uses_unset_for_optional_types_when_nothing_is_passed():
+    @strawberry.input
+    class Number:
+        value: int
+
+    @strawberry.input
+    class Input:
+        numbers: typing.Optional[Number] = None
+        numbers_second: typing.Optional[Number] = None
+
+    # case 1
+    args = {"input": {}}
+
+    annotations = {"input": Input}
+
+    assert convert_args(args, annotations) == {"input": Input(UNSET, UNSET)}
+
+    # case 2
+    args = {"input": {"numbersSecond": None}}
+
+    annotations = {"input": Input}
+
+    assert convert_args(args, annotations) == {"input": Input(UNSET, None)}