Repository: mirumee/ariadne
Pull request #1007 (2023-01-17 15:59:06)

Add option for names conversion to `make_executable_schema`
Supersedes #905

Adds `convert_names_case` to `make_executable_schema`. This option accepts bool or callable:

If `convert_names_case` is set to `True`, `make_executable_schema` will walk the schema:

- For fields without resolver, new resolver will be set that converts field's name to python case using `convert_camel_case_to_snake`. Eg. `userName` will be resolved to `user_name`.
- For input fields `out_name` will be set from its name, meaning that `isClosed` field will be stored on `is_closed` dictionary key passed to resolver 
- For arguments `out_name`  will be set from argument name, meaning that `sendWelcomeEmail: bool` argument's value will be passed to `send_welcome_email` resolver's kwarg.

If `convert_names_case` is a function, `make_executable_schema` will walk the schema:

- For fields without resolver, `convert_names_case` will be called with GraphQL name as first argument, schema as second and tuple with path to schema item as third arguments. This function will have to return a name to resolve to by default resolver.
-  For input fields, `convert_names_case` with field's name as first argument, schema as second and tuple with path to schema item as third arguments. This function will have to return a key name to put input's value for field in python dict.
-  For fields arguments, `convert_names_case` with field's name as first argument, schema as second and tuple with path to schema item as third arguments. This function will have to return a python kwarg name to put input's value for field argument.

Example custom converter signature is:

```python
def convert_names(graphql_name: str, schema: GraphQLSchema, path: Tuple[str, ...]) -> str
    return graphql_name
```

codecov[bot]: # [Codecov](https://codecov.io/gh/mirumee/ariadne/pull/1007?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) Report
> Merging [#1007](https://codecov.io/gh/mirumee/ariadne/pull/1007?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) (2e8cee1) into [master](https://codecov.io/gh/mirumee/ariadne/commit/9a6cd62c9172b5e43cc62cccdec9f28294d7238d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) (9a6cd62) will **decrease** coverage by `0.31%`.
> The diff coverage is `37.14%`.

```diff
@@            Coverage Diff             @@
##           master    #1007      +/-   ##
==========================================
- Coverage   98.09%   97.79%   -0.31%     
==========================================
  Files         110      111       +1     
  Lines        7031     7066      +35     
==========================================
+ Hits         6897     6910      +13     
- Misses        134      156      +22     
```


| [Impacted Files](https://codecov.io/gh/mirumee/ariadne/pull/1007?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) | Coverage Δ | |
|---|---|---|
| [ariadne/schema\_names.py](https://codecov.io/gh/mirumee/ariadne/pull/1007?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee#diff-YXJpYWRuZS9zY2hlbWFfbmFtZXMucHk=) | `34.37% <34.37%> (ø)` | |
| [ariadne/executable\_schema.py](https://codecov.io/gh/mirumee/ariadne/pull/1007?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee#diff-YXJpYWRuZS9leGVjdXRhYmxlX3NjaGVtYS5weQ==) | `97.22% <66.66%> (-2.78%)` | :arrow_down: |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 71e21a0..ae212e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
 - Added `explorer` option to ASGI and WSGI `GraphQL` applications that enables API explorer customization.
 - Added `ExplorerHttp405` API explorer that returns `405 Method Not Allowed` for GET HTTP requests.
 - Added implementations for GraphiQL2, GraphQL-Playground and Apollo Sandbox explorers.
+- Added `convert_names_case` option to `make_executable_schema` to convert all names in schema to Python case using default or custom strategy.
 - Changed `logger` option to also support `Logger` and `LoggerAdapter` instance in addition to `str` with logger name.
 - Added support for `@tag` directive used by Apollo Federation.
 - Moved project configuration from `setup.py` to `pyproject.toml`.
diff --git a/ariadne/__init__.py b/ariadne/__init__.py
index 13e88dc..fe2016d 100644
--- a/ariadne/__init__.py
+++ b/ariadne/__init__.py
@@ -27,6 +27,7 @@ from .resolvers import (
     snake_case_fallback_resolvers,
 )
 from .scalars import ScalarType
+from .schema_names import SchemaNameConverter, convert_schema_names
 from .schema_visitor import SchemaDirectiveVisitor
 from .subscriptions import SubscriptionType
 from .types import SchemaBindable
@@ -49,12 +50,14 @@ __all__ = [
     "ScalarType",
     "SchemaBindable",
     "SchemaDirectiveVisitor",
+    "SchemaNameConverter",
     "SnakeCaseFallbackResolversSetter",
     "SubscriptionType",
     "UnionType",
     "combine_multipart_data",
     "convert_camel_case_to_snake",
     "convert_kwargs_to_snake_case",
+    "convert_schema_names",
     "fallback_resolvers",
     "format_error",
     "get_error_extension",
diff --git a/ariadne/enums.py b/ariadne/enums.py
index 9a2e753..50f42ee 100644
--- a/ariadne/enums.py
+++ b/ariadne/enums.py
@@ -27,6 +27,7 @@ from graphql.type.definition import (
     GraphQLInputField,
     GraphQLInputObjectType,
     GraphQLInputType,
+    GraphQLInterfaceType,
     GraphQLNonNull,
     GraphQLObjectType,
     GraphQLScalarType,
@@ -144,8 +145,9 @@ def enum_values_in_types(
 
 
 @enum_values_in_types.register(GraphQLObjectType)
+@enum_values_in_types.register(GraphQLInterfaceType)
 def enum_values_in_object_type(
-    type_: GraphQLObjectType,
+    type_: Union[GraphQLObjectType, GraphQLInterfaceType],
     field_name: str,
 ) -> Generator[ArgumentWithKeys, None, None]:
     for field in type_.fields.values():
diff --git a/ariadne/executable_schema.py b/ariadne/executable_schema.py
index 1609d61..3bf0dfb 100644
--- a/ariadne/executable_schema.py
+++ b/ariadne/executable_schema.py
@@ -12,6 +12,7 @@ from .enums import (
     set_default_enum_values_on_schema,
     validate_schema_enum_values,
 )
+from .schema_names import SchemaNameConverter, convert_schema_names
 from .schema_visitor import SchemaDirectiveVisitor
 from .types import SchemaBindable
 
@@ -20,6 +21,7 @@ def make_executable_schema(
     type_defs: Union[str, List[str]],
     *bindables: Union[SchemaBindable, List[SchemaBindable]],
     directives: Optional[Dict[str, Type[SchemaDirectiveVisitor]]] = None,
+    convert_names_case: Union[bool, SchemaNameConverter] = False,
 ) -> GraphQLSchema:
     if isinstance(type_defs, list):
         type_defs = join_type_defs(type_defs)
@@ -40,6 +42,12 @@ def make_executable_schema(
     validate_schema_enum_values(schema)
     repair_default_enum_values(schema, flat_bindables)
 
+    if convert_names_case:
+        convert_schema_names(
+            schema,
+            convert_names_case if callable(convert_names_case) else None,
+        )
+
     return schema
 
 
diff --git a/ariadne/schema_names.py b/ariadne/schema_names.py
new file mode 100644
index 0000000..6f699cc
--- /dev/null
+++ b/ariadne/schema_names.py
@@ -0,0 +1,91 @@
+from typing import Callable, Optional, Tuple
+
+from graphql import (
+    GraphQLField,
+    GraphQLInputObjectType,
+    GraphQLObjectType,
+    GraphQLSchema,
+)
+
+from .resolvers import resolve_to
+from .utils import convert_camel_case_to_snake
+
+SchemaNameConverter = Callable[[str, GraphQLSchema, Tuple[str, ...]], str]
+
+GRAPHQL_SPEC_TYPES = (
+    "__Directive",
+    "__EnumValue",
+    "__Field",
+    "__InputValue",
+    "__Schema",
+    "__Type",
+)
+
+
+def convert_schema_names(
+    schema: GraphQLSchema,
+    name_converter: Optional[SchemaNameConverter],
+) -> None:
+    name_converter = name_converter or default_schema_name_converter
+
+    for type_name, graphql_type in schema.type_map.items():
+        if (
+            isinstance(graphql_type, GraphQLObjectType)
+            and type_name not in GRAPHQL_SPEC_TYPES
+        ):
+            convert_names_in_schema_object(name_converter, graphql_type, schema)
+        if isinstance(graphql_type, GraphQLInputObjectType):
+            convert_names_in_schema_input(name_converter, graphql_type, schema)
+
+
+def convert_names_in_schema_object(
+    name_converter: SchemaNameConverter,
+    graphql_object: GraphQLObjectType,
+    schema: GraphQLSchema,
+) -> None:
+    for field_name, field in graphql_object.fields.items():
+        if field.args:
+            convert_names_in_schema_args(
+                name_converter, field, schema, graphql_object.name, field_name
+            )
+
+        if field.resolve or field_name.lower() == field_name:
+            continue
+
+        field.resolve = resolve_to(
+            name_converter(field_name, schema, (graphql_object.name, field_name))
+        )
+
+
+def convert_names_in_schema_args(
+    name_converter: SchemaNameConverter,
+    graphql_field: GraphQLField,
+    schema: GraphQLSchema,
+    object_name: str,
+    field_name: str,
+) -> None:
+    for arg_name, arg in graphql_field.args.items():
+        if arg.out_name or arg_name.lower() == arg_name:
+            continue
+
+        arg.out_name = name_converter(
+            arg_name, schema, (object_name, field_name, arg_name)
+        )
+
+
+def convert_names_in_schema_input(
+    name_converter: SchemaNameConverter,
+    graphql_input: GraphQLInputObjectType,
+    schema: GraphQLSchema,
+) -> None:
+    for field_name, field in graphql_input.fields.items():
+        if field.out_name or field_name.lower() == field_name:
+            continue
+
+        field.out_name = name_converter(
+            field_name, schema, (graphql_input.name, field_name)
+        )
+
+
+def default_schema_name_converter(graphql_name: str, *_) -> str:
+    return convert_camel_case_to_snake(graphql_name)


Test patch:
diff --git a/tests/test_enums.py b/tests/test_enums.py
index b85b790..32b8047 100644
--- a/tests/test_enums.py
+++ b/tests/test_enums.py
@@ -372,9 +372,11 @@ def test_input_exc_schema_raises_exception_for_undefined_enum_value_in_nested_fi
 def test_find_enum_values_in_schema_for_undefined_and_invalid_values():
     input_schema = """
         type Query {
-            complex(hello: Episode = EMPIRE,
-                    hi: Test = { role: JEDI,  next_role: ENTERPRISE, ignore: "HI"}, 
-                    bonjour: BetterTest = {newIgnore: "Witam", test: { role: NEWHOPE }}): String
+            complex(
+                hello: Episode = EMPIRE,
+                hi: Test = { role: JEDI,  next_role: ENTERPRISE, ignore: "HI"}, 
+                bonjour: BetterTest = {newIgnore: "Witam", test: { role: NEWHOPE }}
+            ): String
         }
     
         input Test {
@@ -387,13 +389,17 @@ def test_find_enum_values_in_schema_for_undefined_and_invalid_values():
             newIgnore: String
             test: Test = { role: NEWHOPE }
         }
+
+        interface Result {
+            hello(r: Episode = ENTERPRISE): String
+        }
     """
     query_complex_keys = [["next_role"], ["role"], ["test", "role"]]
     better_test_complex_keys = [["role"]]
-    number_of_defined_enum_values = 7
+    number_of_defined_enum_values = 8
 
     # 2 Undefined because of "ENTERPRISE" invalid value, and next_role in Test input
-    number_of_undefined_default_enum_values = 3
+    number_of_undefined_default_enum_values = 4
 
     ast_document = parse(join_type_defs([enum_definition, input_schema]))
     schema = build_ast_schema(ast_document)
@@ -413,6 +419,7 @@ def test_find_enum_values_in_schema_for_undefined_and_invalid_values():
 
 def test_enum_type_is_able_to_represent_enum_default_value_in_schema():
     #  regression test for: https://github.com/mirumee/ariadne/issues/293
+    #  regression test for: https://github.com/mirumee/ariadne/issues/995
 
     type_defs = """
         enum Role {
@@ -422,6 +429,15 @@ def test_enum_type_is_able_to_represent_enum_default_value_in_schema():
 
         type Query {
             hello(r: Role = USER): String
+            results: [Result!]!
+        }
+
+        interface Result {
+            hello(r: Role = USER): String
+        }
+
+        type User implements Result {
+            hello(r: Role = USER): String
         }
     """
 
@@ -445,6 +461,8 @@ def test_enum_type_is_able_to_represent_enum_default_value_in_schema():
 
     query = "{__schema{types{name,fields{name,args{name,defaultValue}}}}}"
     _, result = ariadne_graphql_sync(schema, {"query": query}, debug=True)
+    assert not result.get("errors")
+
     types_map = {
         result_type["name"]: result_type
         for result_type in result["data"]["__schema"]["types"]
@@ -453,5 +471,6 @@ def test_enum_type_is_able_to_represent_enum_default_value_in_schema():
 
     result_hello_query = graphql_sync(schema, "{hello}")
     assert types_map["Query"]["fields"][0]["args"][0]["defaultValue"] == "USER"
+    assert types_map["User"]["fields"][0]["args"][0]["defaultValue"] == "USER"
     assert result_hello_query.data["hello"]
     assert result_hello_query.errors is None
diff --git a/tests/test_schema_names_conversion.py b/tests/test_schema_names_conversion.py
new file mode 100644
index 0000000..f2b5021
--- /dev/null
+++ b/tests/test_schema_names_conversion.py
@@ -0,0 +1,243 @@
+from graphql import graphql_sync
+
+from ariadne import QueryType, gql, make_executable_schema
+
+
+def test_field_names_without_resolvers_are_converted():
+    type_defs = gql(
+        """
+        type Query {
+            field: String
+            convertedField: String
+        }
+        """
+    )
+
+    schema = make_executable_schema(type_defs, convert_names_case=True)
+    result = graphql_sync(
+        schema,
+        "{ field convertedField }",
+        root_value={
+            "field": "works",
+            "converted_field": "okay",
+        },
+    )
+
+    assert result.data == {"field": "works", "convertedField": "okay"}
+
+
+def test_field_names_with_resolvers_are_not_converted():
+    type_defs = gql(
+        """
+        type Query {
+            field: String
+            convertedField: String
+        }
+        """
+    )
+
+    query_type = QueryType()
+    query_type.set_field("convertedField", lambda *_: "lambda")
+
+    schema = make_executable_schema(type_defs, query_type, convert_names_case=True)
+    result = graphql_sync(
+        schema,
+        "{ field convertedField }",
+        root_value={
+            "field": "works",
+            "converted_field": "okay",
+        },
+    )
+
+    assert result.data == {"field": "works", "convertedField": "lambda"}
+
+
+def test_field_names_without_need_for_conversion_are_skipped():
+    type_defs = gql(
+        """
+        type Query {
+            field: String
+            convertedField: String
+        }
+        """
+    )
+
+    schema = make_executable_schema(type_defs, convert_names_case=True)
+    assert schema.type_map["Query"].fields["field"].resolve is None
+    assert schema.type_map["Query"].fields["convertedField"].resolve is not None
+
+
+def test_fields_arguments_are_converted():
+    type_defs = gql(
+        """
+        type Query {
+            field(arg: String, otherArg: Int): String
+            convertedField(arg: String, otherArg: Int): String
+        }
+        """
+    )
+
+    schema = make_executable_schema(type_defs, convert_names_case=True)
+    query_type = schema.type_map["Query"]
+    field = query_type.fields["field"]
+    converted_field = query_type.fields["convertedField"]
+
+    # Args that don't need conversion are skipped
+    assert field.args["arg"].out_name is None
+    assert converted_field.args["arg"].out_name is None
+
+    # Input field names that need conversion have out_name set
+    assert field.args["otherArg"].out_name == "other_arg"
+    assert converted_field.args["otherArg"].out_name == "other_arg"
+
+
+def test_inputs_field_names_are_converted():
+    type_defs = gql(
+        """
+        type Query {
+            _skip: String
+        }
+
+        input Test {
+            field: String
+            convertedField: String
+        }
+        """
+    )
+
+    schema = make_executable_schema(type_defs, convert_names_case=True)
+    input_type = schema.type_map["Test"]
+
+    # Input field names that don't need conversion are skipped
+    assert input_type.fields["field"].out_name is None
+
+    # Input field names that need conversion have out_name set
+    assert input_type.fields["convertedField"].out_name == "converted_field"
+
+
+def test_fields_converted_argument_names_are_used():
+    type_defs = gql(
+        """
+        type Query {
+            test(arg: String, otherArg: String): String
+        }
+        """
+    )
+
+    query_type = QueryType()
+
+    @query_type.field("test")
+    def resolve_test(*_, arg=None, other_arg=None):
+        return arg + other_arg
+
+    schema = make_executable_schema(type_defs, query_type, convert_names_case=True)
+
+    result = graphql_sync(schema, '{ test(arg: "Lorem", otherArg: "Ipsum") }')
+    assert result.data == {"test": "LoremIpsum"}
+
+
+def test_inputs_converted_fields_names_are_used():
+    # pylint: disable=redefined-builtin
+    type_defs = gql(
+        """
+        type Query {
+            test(input: TestInput): String
+        }
+
+        input TestInput {
+            field: String
+            convertedField: String
+        }
+        """
+    )
+
+    query_type = QueryType()
+
+    @query_type.field("test")
+    def resolve_test(*_, input=None):
+        return input["field"] + input["converted_field"]
+
+    schema = make_executable_schema(type_defs, query_type, convert_names_case=True)
+
+    result = graphql_sync(
+        schema, '{ test(input: { field: "Lorem", convertedField: "Ipsum"}) }'
+    )
+    assert result.data == {"test": "LoremIpsum"}
+
+
+def custom_converter(graphql_name, schema, path):
+    assert schema
+    assert path
+    assert path[-1] == graphql_name
+    return f"custom_{graphql_name}"
+
+
+def test_field_names_are_converted_using_custom_strategy():
+    type_defs = gql(
+        """
+        type Query {
+            field: String
+            convertedField: String
+        }
+        """
+    )
+
+    schema = make_executable_schema(type_defs, convert_names_case=custom_converter)
+    result = graphql_sync(
+        schema,
+        "{ field convertedField }",
+        root_value={
+            "field": "works",
+            "custom_convertedField": "okay",
+        },
+    )
+
+    assert result.data == {"field": "works", "convertedField": "okay"}
+
+
+def test_field_arguments_are_converted_using_custom_strategy():
+    type_defs = gql(
+        """
+        type Query {
+            field(arg: String, otherArg: Int): String
+            convertedField(arg: String, otherArg: Int): String
+        }
+        """
+    )
+
+    schema = make_executable_schema(type_defs, convert_names_case=custom_converter)
+    query_type = schema.type_map["Query"]
+    field = query_type.fields["field"]
+    converted_field = query_type.fields["convertedField"]
+
+    # Args that don't need conversion are skipped
+    assert field.args["arg"].out_name is None
+    assert converted_field.args["arg"].out_name is None
+
+    # Input field names that need conversion have out_name set
+    assert field.args["otherArg"].out_name == "custom_otherArg"
+    assert converted_field.args["otherArg"].out_name == "custom_otherArg"
+
+
+def test_inputs_field_names_are_converted_using_custom_strategy():
+    type_defs = gql(
+        """
+        type Query {
+            _skip: String
+        }
+
+        input Test {
+            field: String
+            convertedField: String
+        }
+        """
+    )
+
+    schema = make_executable_schema(type_defs, convert_names_case=custom_converter)
+    input_type = schema.type_map["Test"]
+
+    # Input field names that don't need conversion are skipped
+    assert input_type.fields["field"].out_name is None
+
+    # Input field names that need conversion have out_name set
+    assert input_type.fields["convertedField"].out_name == "custom_convertedField"