Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Redo GraphQL enum fixing logic #1138

Merged
merged 12 commits into from
Dec 15, 2023
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@

## 0.22 (UNRELEASED)

- Deprecated `EnumType.bind_to_default_values` method. It will be removed in a future release.
- Added `repair_schema_default_enum_values` to public API.
- Removed `validate_schema_enum_values` and introduced `validate_schema_default_enum_values` in its place. This is a breaking change.


## 0.21 (2023-11-08)

- Added Python 3.12 to tested versions.
- Updated integration tests to run against FastAPI 0.104, Flask 3.0 and Starlette 0.32.
- Updated GraphiQL explorer to initialize with support for subscriptions.


## 0.21 (2023-06-27)
## 0.20.1 (2023-06-27)

- Updated the GraphiQL template with working explorer plugin URL.

Expand Down
12 changes: 6 additions & 6 deletions ariadne/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from .enums import (
EnumType,
set_default_enum_values_on_schema,
validate_schema_enum_values,
from .enums import EnumType
from .enums_default_values import (
repair_schema_default_enum_values,
validate_schema_default_enum_values,
)
from .executable_schema import make_executable_schema
from .extensions import ExtensionManager
Expand Down Expand Up @@ -70,12 +70,12 @@
"is_default_resolver",
"load_schema_from_path",
"make_executable_schema",
"repair_schema_default_enum_values",
"resolve_to",
"set_default_enum_values_on_schema",
"snake_case_fallback_resolvers",
"subscribe",
"type_implements_interface",
"unwrap_graphql_error",
"upload_scalar",
"validate_schema_enum_values",
"validate_schema_default_enum_values",
]
269 changes: 14 additions & 255 deletions ariadne/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,49 +2,18 @@
from typing import (
Any,
Dict,
Generator,
List,
Mapping,
Optional,
Tuple,
Type,
TypeVar,
Union,
cast,
)
from functools import reduce, singledispatch
import operator
import warnings

from graphql.type import GraphQLEnumType, GraphQLNamedType, GraphQLSchema
from graphql.language.ast import (
EnumValueNode,
InputValueDefinitionNode,
ObjectValueNode,
)
from graphql.pyutils.undefined import Undefined
from graphql.type.definition import (
GraphQLArgument,
GraphQLField,
GraphQLInputField,
GraphQLInputObjectType,
GraphQLInputType,
GraphQLInterfaceType,
GraphQLNonNull,
GraphQLObjectType,
GraphQLScalarType,
)

from .types import SchemaBindable


T = TypeVar("T")
ArgumentWithKeys = Tuple[str, str, GraphQLArgument, Optional[List["str"]]]
InputFieldWithKeys = Tuple[str, str, GraphQLInputField, Optional[List["str"]]]
GraphQLNamedInputType = Union[
GraphQLScalarType, GraphQLEnumType, GraphQLInputObjectType
]


class EnumType(SchemaBindable):
"""Bindable mapping Python values to enumeration members in a GraphQL schema.

Expand Down Expand Up @@ -118,34 +87,27 @@
)
graphql_type.values[key].value = value

def bind_to_default_values(self, schema: GraphQLSchema) -> None:
def bind_to_default_values(self, _schema: GraphQLSchema) -> None:
"""Populates default values of input fields and args in the GraphQL schema.

This step is required because GraphQL query executor doesn't perform a
lookup for default values defined in schema. Instead it simply pulls the
value from fields and arguments `default_value` attribute, which is
`None` by default.
"""
for _, _, arg, key_list in find_enum_values_in_schema(schema):
type_ = resolve_null_type(arg.type)
type_ = cast(GraphQLNamedInputType, type_)

if (
key_list is None
and arg.default_value in self.values
and type_.name == self.name
):
type_ = resolve_null_type(arg.type)
arg.default_value = self.values[arg.default_value]

elif key_list is not None:
enum_value = get_value_from_mapping_value(arg.default_value, key_list)
type_ = cast(GraphQLEnumType, track_type_for_nested(arg, key_list))
> **Deprecated:** Ariadne versions before 0.22 used
`EnumType.bind_to_default_values` method to fix default enum values embedded
in the GraphQL schema. Ariadne 0.22 release introduces universal
`repair_schema_default_enum_values` utility in its place.
"""

if enum_value in self.values and type_.name == self.name:
set_leaf_value_in_mapping(
arg.default_value, key_list, self.values[enum_value]
)
warnings.warn(

Check warning on line 104 in ariadne/enums.py

View check run for this annotation

Codecov / codecov/patch

ariadne/enums.py#L104

Added line #L104 was not covered by tests
(
"'EnumType.bind_to_default_values' was deprecated in Ariadne 0.22 and "
"will be removed in a future release."
),
DeprecationWarning,
)

def validate_graphql_type(self, graphql_type: Optional[GraphQLNamedType]) -> None:
"""Validates that schema's GraphQL type associated with this `EnumType`
Expand All @@ -157,206 +119,3 @@
"%s is defined in the schema, but it is instance of %s (expected %s)"
% (self.name, type(graphql_type).__name__, GraphQLEnumType.__name__)
)


def set_default_enum_values_on_schema(schema: GraphQLSchema):
"""Sets missing Python values for GraphQL enums in schema.

Recursively scans GraphQL schema for enums and their values. If `value`
attribute is empty, its populated with with a string of its GraphQL name.

This string is then used to represent enum's value in Python instead of `None`.

# Requires arguments

`schema`: a GraphQL schema to set enums default values in.
"""
for type_object in schema.type_map.values():
if isinstance(type_object, GraphQLEnumType):
set_default_enum_values(type_object)


def set_default_enum_values(graphql_type: GraphQLEnumType):
for key in graphql_type.values:
if graphql_type.values[key].value is None:
graphql_type.values[key].value = key


def validate_schema_enum_values(schema: GraphQLSchema) -> None:
"""Raises `ValueError` if GraphQL schema has input fields or arguments with
default values that are undefined enum values.

# Example schema with invalid field argument

This schema fails to validate because argument `role` on field `users`
specifies `REVIEWER` as default value and `REVIEWER` is not a member of
the `UserRole` enum:

```graphql
type Query {
users(role: UserRole = REVIEWER): [User!]!
}

enum UserRole {
MEMBER
MODERATOR
ADMIN
}

type User {
id: ID!
}
```

# Example schema with invalid input field

This schema fails to validate because field `role` on input `UserFilters`
specifies `REVIEWER` as default value and `REVIEWER` is not a member of
the `UserRole` enum:

```graphql
type Query {
users(filter: UserFilters): [User!]!
}

input UserFilters {
name: String
role: UserRole = REVIEWER
}

enum UserRole {
MEMBER
MODERATOR
ADMIN
}

type User {
id: ID!
}
```
"""

for type_name, field_name, arg, _ in find_enum_values_in_schema(schema):
if is_invalid_enum_value(arg):
raise ValueError(
f"Value for type: <{arg.type}> is invalid. "
f"Check InputField/Arguments for <{field_name}> in <{type_name}> "
"(Undefined enum value)."
)


def is_invalid_enum_value(field: Union[GraphQLInputField, GraphQLArgument]) -> bool:
if field.ast_node is None:
return False
return field.default_value is Undefined and field.ast_node.default_value is not None


def find_enum_values_in_schema(
schema: GraphQLSchema,
) -> Generator[Union[ArgumentWithKeys, InputFieldWithKeys], None, None]:
for name, type_ in schema.type_map.items():
result = enum_values_in_types(type_, name)
if result is not None:
yield from result


@singledispatch
def enum_values_in_types(
type_: GraphQLNamedType, # pylint: disable=unused-argument
name: str, # pylint: disable=unused-argument
) -> Optional[Generator[Union[ArgumentWithKeys, InputFieldWithKeys], None, None]]:
pass


@enum_values_in_types.register(GraphQLObjectType)
@enum_values_in_types.register(GraphQLInterfaceType)
def enum_values_in_object_type(
type_: Union[GraphQLObjectType, GraphQLInterfaceType],
field_name: str,
) -> Generator[ArgumentWithKeys, None, None]:
for field in type_.fields.values():
yield from enum_values_in_field_args(field_name, field)


@enum_values_in_types.register(GraphQLInputObjectType)
def enum_values_in_input_type(
type_: GraphQLInputObjectType,
field_name,
) -> Generator[InputFieldWithKeys, None, None]:
yield from _get_field_with_keys(field_name, type_.fields.items())


def enum_values_in_field_args(
field_name: str,
field: GraphQLField,
) -> Generator[ArgumentWithKeys, None, None]:
args = [
(name, arg)
for name, arg in field.args.items()
if isinstance(
arg.type, (GraphQLInputObjectType, GraphQLEnumType, GraphQLNonNull)
)
]

yield from _get_field_with_keys(field_name, args)


def _get_field_with_keys(field_name, fields):
for input_name, field in fields:
resolved_type = resolve_null_type(field.type)
if isinstance(resolved_type, GraphQLEnumType):
yield field_name, input_name, field, None

if isinstance(resolved_type, GraphQLInputObjectType):
if (
field.ast_node is not None
and field.ast_node.default_value is not None
and isinstance(field.ast_node.default_value, ObjectValueNode)
):
routes = get_enum_keys_from_ast(field.ast_node)
for route in routes:
yield field_name, input_name, field, route


def get_enum_keys_from_ast(ast_node: InputValueDefinitionNode) -> List[List["str"]]:
routes = []
object_node = cast(ObjectValueNode, ast_node.default_value)
nodes = [([field.name.value], field) for field in object_node.fields]

while nodes:
key_list, field = nodes.pop()
if isinstance(field.value, EnumValueNode):
routes.append(key_list)

if isinstance(field.value, ObjectValueNode):
for new_field in field.value.fields:
new_route = key_list[:]
new_route.append(new_field.name.value)
nodes.append((new_route, new_field))

return routes


def get_value_from_mapping_value(mapping: Mapping[T, Any], key_list: List[T]) -> Any:
return reduce(operator.getitem, key_list, mapping)


def set_leaf_value_in_mapping(
mapping: Mapping[T, Any], key_list: List[T], value: Any
) -> None:
get_value_from_mapping_value(mapping, key_list[:-1])[key_list[-1]] = value


def track_type_for_nested(
arg: Union[GraphQLArgument, GraphQLInputField], key_list: List[str]
) -> GraphQLInputType:
type_ = resolve_null_type(arg.type)

for elem in key_list:
if isinstance(type_, GraphQLInputObjectType):
type_ = type_.fields[elem].type
return type_


def resolve_null_type(type_: GraphQLInputType) -> GraphQLInputType:
return type_.of_type if isinstance(type_, GraphQLNonNull) else type_
Loading