Files
zenml/venv/lib/python3.9/site-packages/graphql/utilities/find_breaking_changes.py
Christian Mantha 2ca0b9ef7c star
2026-03-02 19:10:52 -05:00

621 lines
20 KiB
Python

from enum import Enum
from typing import Any, Collection, Dict, List, NamedTuple, Union, cast
from ..language import print_ast
from ..pyutils import inspect, Undefined
from ..type import (
GraphQLEnumType,
GraphQLField,
GraphQLList,
GraphQLNamedType,
GraphQLNonNull,
GraphQLInputType,
GraphQLInterfaceType,
GraphQLObjectType,
GraphQLSchema,
GraphQLType,
GraphQLUnionType,
is_enum_type,
is_input_object_type,
is_interface_type,
is_list_type,
is_named_type,
is_non_null_type,
is_object_type,
is_required_argument,
is_required_input_field,
is_scalar_type,
is_specified_scalar_type,
is_union_type,
)
from ..utilities.sort_value_node import sort_value_node
from .ast_from_value import ast_from_value
__all__ = [
"BreakingChange",
"BreakingChangeType",
"DangerousChange",
"DangerousChangeType",
"find_breaking_changes",
"find_dangerous_changes",
]
class BreakingChangeType(Enum):
TYPE_REMOVED = 10
TYPE_CHANGED_KIND = 11
TYPE_REMOVED_FROM_UNION = 20
VALUE_REMOVED_FROM_ENUM = 21
REQUIRED_INPUT_FIELD_ADDED = 22
IMPLEMENTED_INTERFACE_REMOVED = 23
FIELD_REMOVED = 30
FIELD_CHANGED_KIND = 31
REQUIRED_ARG_ADDED = 40
ARG_REMOVED = 41
ARG_CHANGED_KIND = 42
DIRECTIVE_REMOVED = 50
DIRECTIVE_ARG_REMOVED = 51
REQUIRED_DIRECTIVE_ARG_ADDED = 52
DIRECTIVE_REPEATABLE_REMOVED = 53
DIRECTIVE_LOCATION_REMOVED = 54
class DangerousChangeType(Enum):
VALUE_ADDED_TO_ENUM = 60
TYPE_ADDED_TO_UNION = 61
OPTIONAL_INPUT_FIELD_ADDED = 62
OPTIONAL_ARG_ADDED = 63
IMPLEMENTED_INTERFACE_ADDED = 64
ARG_DEFAULT_VALUE_CHANGE = 65
class BreakingChange(NamedTuple):
type: BreakingChangeType
description: str
class DangerousChange(NamedTuple):
type: DangerousChangeType
description: str
Change = Union[BreakingChange, DangerousChange]
def find_breaking_changes(
old_schema: GraphQLSchema, new_schema: GraphQLSchema
) -> List[BreakingChange]:
"""Find breaking changes.
Given two schemas, returns a list containing descriptions of all the types of
breaking changes covered by the other functions down below.
"""
return [
change
for change in find_schema_changes(old_schema, new_schema)
if isinstance(change.type, BreakingChangeType)
]
def find_dangerous_changes(
old_schema: GraphQLSchema, new_schema: GraphQLSchema
) -> List[DangerousChange]:
"""Find dangerous changes.
Given two schemas, returns a list containing descriptions of all the types of
potentially dangerous changes covered by the other functions down below.
"""
return [
change
for change in find_schema_changes(old_schema, new_schema)
if isinstance(change.type, DangerousChangeType)
]
def find_schema_changes(
old_schema: GraphQLSchema, new_schema: GraphQLSchema
) -> List[Change]:
return find_type_changes(old_schema, new_schema) + find_directive_changes(
old_schema, new_schema
)
def find_directive_changes(
old_schema: GraphQLSchema, new_schema: GraphQLSchema
) -> List[Change]:
schema_changes: List[Change] = []
directives_diff = list_diff(old_schema.directives, new_schema.directives)
for directive in directives_diff.removed:
schema_changes.append(
BreakingChange(
BreakingChangeType.DIRECTIVE_REMOVED, f"{directive.name} was removed."
)
)
for old_directive, new_directive in directives_diff.persisted:
args_diff = dict_diff(old_directive.args, new_directive.args)
for arg_name, new_arg in args_diff.added.items():
if is_required_argument(new_arg):
schema_changes.append(
BreakingChange(
BreakingChangeType.REQUIRED_DIRECTIVE_ARG_ADDED,
f"A required arg {arg_name} on directive"
f" {old_directive.name} was added.",
)
)
for arg_name in args_diff.removed:
schema_changes.append(
BreakingChange(
BreakingChangeType.DIRECTIVE_ARG_REMOVED,
f"{arg_name} was removed from {new_directive.name}.",
)
)
if old_directive.is_repeatable and not new_directive.is_repeatable:
schema_changes.append(
BreakingChange(
BreakingChangeType.DIRECTIVE_REPEATABLE_REMOVED,
f"Repeatable flag was removed from {old_directive.name}.",
)
)
for location in old_directive.locations:
if location not in new_directive.locations:
schema_changes.append(
BreakingChange(
BreakingChangeType.DIRECTIVE_LOCATION_REMOVED,
f"{location.name} was removed from {new_directive.name}.",
)
)
return schema_changes
def find_type_changes(
old_schema: GraphQLSchema, new_schema: GraphQLSchema
) -> List[Change]:
schema_changes: List[Change] = []
types_diff = dict_diff(old_schema.type_map, new_schema.type_map)
for type_name, old_type in types_diff.removed.items():
schema_changes.append(
BreakingChange(
BreakingChangeType.TYPE_REMOVED,
(
f"Standard scalar {type_name} was removed"
" because it is not referenced anymore."
if is_specified_scalar_type(old_type)
else f"{type_name} was removed."
),
)
)
for type_name, (old_type, new_type) in types_diff.persisted.items():
if is_enum_type(old_type) and is_enum_type(new_type):
schema_changes.extend(find_enum_type_changes(old_type, new_type))
elif is_union_type(old_type) and is_union_type(new_type):
schema_changes.extend(find_union_type_changes(old_type, new_type))
elif is_input_object_type(old_type) and is_input_object_type(new_type):
schema_changes.extend(find_input_object_type_changes(old_type, new_type))
elif is_object_type(old_type) and is_object_type(new_type):
schema_changes.extend(find_field_changes(old_type, new_type))
schema_changes.extend(
find_implemented_interfaces_changes(old_type, new_type)
)
elif is_interface_type(old_type) and is_interface_type(new_type):
schema_changes.extend(find_field_changes(old_type, new_type))
schema_changes.extend(
find_implemented_interfaces_changes(old_type, new_type)
)
elif old_type.__class__ is not new_type.__class__:
schema_changes.append(
BreakingChange(
BreakingChangeType.TYPE_CHANGED_KIND,
f"{type_name} changed from {type_kind_name(old_type)}"
f" to {type_kind_name(new_type)}.",
)
)
return schema_changes
def find_input_object_type_changes(
old_type: Union[GraphQLObjectType, GraphQLInterfaceType],
new_type: Union[GraphQLObjectType, GraphQLInterfaceType],
) -> List[Change]:
schema_changes: List[Change] = []
fields_diff = dict_diff(old_type.fields, new_type.fields)
for field_name, new_field in fields_diff.added.items():
if is_required_input_field(new_field):
schema_changes.append(
BreakingChange(
BreakingChangeType.REQUIRED_INPUT_FIELD_ADDED,
f"A required field {field_name} on"
f" input type {old_type.name} was added.",
)
)
else:
schema_changes.append(
DangerousChange(
DangerousChangeType.OPTIONAL_INPUT_FIELD_ADDED,
f"An optional field {field_name} on"
f" input type {old_type.name} was added.",
)
)
for field_name in fields_diff.removed:
schema_changes.append(
BreakingChange(
BreakingChangeType.FIELD_REMOVED,
f"{old_type.name}.{field_name} was removed.",
)
)
for field_name, (old_field, new_field) in fields_diff.persisted.items():
is_safe = is_change_safe_for_input_object_field_or_field_arg(
old_field.type, new_field.type
)
if not is_safe:
schema_changes.append(
BreakingChange(
BreakingChangeType.FIELD_CHANGED_KIND,
f"{old_type.name}.{field_name} changed type"
f" from {old_field.type} to {new_field.type}.",
)
)
return schema_changes
def find_union_type_changes(
old_type: GraphQLUnionType, new_type: GraphQLUnionType
) -> List[Change]:
schema_changes: List[Change] = []
possible_types_diff = list_diff(old_type.types, new_type.types)
for possible_type in possible_types_diff.added:
schema_changes.append(
DangerousChange(
DangerousChangeType.TYPE_ADDED_TO_UNION,
f"{possible_type.name} was added" f" to union type {old_type.name}.",
)
)
for possible_type in possible_types_diff.removed:
schema_changes.append(
BreakingChange(
BreakingChangeType.TYPE_REMOVED_FROM_UNION,
f"{possible_type.name} was removed from union type {old_type.name}.",
)
)
return schema_changes
def find_enum_type_changes(
old_type: GraphQLEnumType, new_type: GraphQLEnumType
) -> List[Change]:
schema_changes: List[Change] = []
values_diff = dict_diff(old_type.values, new_type.values)
for value_name in values_diff.added:
schema_changes.append(
DangerousChange(
DangerousChangeType.VALUE_ADDED_TO_ENUM,
f"{value_name} was added to enum type {old_type.name}.",
)
)
for value_name in values_diff.removed:
schema_changes.append(
BreakingChange(
BreakingChangeType.VALUE_REMOVED_FROM_ENUM,
f"{value_name} was removed from enum type {old_type.name}.",
)
)
return schema_changes
def find_implemented_interfaces_changes(
old_type: Union[GraphQLObjectType, GraphQLInterfaceType],
new_type: Union[GraphQLObjectType, GraphQLInterfaceType],
) -> List[Change]:
schema_changes: List[Change] = []
interfaces_diff = list_diff(old_type.interfaces, new_type.interfaces)
for interface in interfaces_diff.added:
schema_changes.append(
DangerousChange(
DangerousChangeType.IMPLEMENTED_INTERFACE_ADDED,
f"{interface.name} added to interfaces implemented by {old_type.name}.",
)
)
for interface in interfaces_diff.removed:
schema_changes.append(
BreakingChange(
BreakingChangeType.IMPLEMENTED_INTERFACE_REMOVED,
f"{old_type.name} no longer implements interface {interface.name}.",
)
)
return schema_changes
def find_field_changes(
old_type: Union[GraphQLObjectType, GraphQLInterfaceType],
new_type: Union[GraphQLObjectType, GraphQLInterfaceType],
) -> List[Change]:
schema_changes: List[Change] = []
fields_diff = dict_diff(old_type.fields, new_type.fields)
for field_name in fields_diff.removed:
schema_changes.append(
BreakingChange(
BreakingChangeType.FIELD_REMOVED,
f"{old_type.name}.{field_name} was removed.",
)
)
for field_name, (old_field, new_field) in fields_diff.persisted.items():
schema_changes.extend(
find_arg_changes(old_type, field_name, old_field, new_field)
)
is_safe = is_change_safe_for_object_or_interface_field(
old_field.type, new_field.type
)
if not is_safe:
schema_changes.append(
BreakingChange(
BreakingChangeType.FIELD_CHANGED_KIND,
f"{old_type.name}.{field_name} changed type"
f" from {old_field.type} to {new_field.type}.",
)
)
return schema_changes
def find_arg_changes(
old_type: Union[GraphQLObjectType, GraphQLInterfaceType],
field_name: str,
old_field: GraphQLField,
new_field: GraphQLField,
) -> List[Change]:
schema_changes: List[Change] = []
args_diff = dict_diff(old_field.args, new_field.args)
for arg_name in args_diff.removed:
schema_changes.append(
BreakingChange(
BreakingChangeType.ARG_REMOVED,
f"{old_type.name}.{field_name} arg" f" {arg_name} was removed.",
)
)
for arg_name, (old_arg, new_arg) in args_diff.persisted.items():
is_safe = is_change_safe_for_input_object_field_or_field_arg(
old_arg.type, new_arg.type
)
if not is_safe:
schema_changes.append(
BreakingChange(
BreakingChangeType.ARG_CHANGED_KIND,
f"{old_type.name}.{field_name} arg"
f" {arg_name} has changed type from"
f" {old_arg.type} to {new_arg.type}.",
)
)
elif old_arg.default_value is not Undefined:
if new_arg.default_value is Undefined:
schema_changes.append(
DangerousChange(
DangerousChangeType.ARG_DEFAULT_VALUE_CHANGE,
f"{old_type.name}.{field_name} arg"
f" {arg_name} defaultValue was removed.",
)
)
else:
# Since we are looking only for client's observable changes we should
# compare default values in the same representation as they are
# represented inside introspection.
old_value_str = stringify_value(old_arg.default_value, old_arg.type)
new_value_str = stringify_value(new_arg.default_value, new_arg.type)
if old_value_str != new_value_str:
schema_changes.append(
DangerousChange(
DangerousChangeType.ARG_DEFAULT_VALUE_CHANGE,
f"{old_type.name}.{field_name} arg"
f" {arg_name} has changed defaultValue"
f" from {old_value_str} to {new_value_str}.",
)
)
for arg_name, new_arg in args_diff.added.items():
if is_required_argument(new_arg):
schema_changes.append(
BreakingChange(
BreakingChangeType.REQUIRED_ARG_ADDED,
f"A required arg {arg_name} on"
f" {old_type.name}.{field_name} was added.",
)
)
else:
schema_changes.append(
DangerousChange(
DangerousChangeType.OPTIONAL_ARG_ADDED,
f"An optional arg {arg_name} on"
f" {old_type.name}.{field_name} was added.",
)
)
return schema_changes
def is_change_safe_for_object_or_interface_field(
old_type: GraphQLType, new_type: GraphQLType
) -> bool:
if is_list_type(old_type):
return (
# if they're both lists, make sure underlying types are compatible
is_list_type(new_type)
and is_change_safe_for_object_or_interface_field(
cast(GraphQLList, old_type).of_type, cast(GraphQLList, new_type).of_type
)
) or (
# moving from nullable to non-null of same underlying type is safe
is_non_null_type(new_type)
and is_change_safe_for_object_or_interface_field(
old_type, cast(GraphQLNonNull, new_type).of_type
)
)
if is_non_null_type(old_type):
# if they're both non-null, make sure underlying types are compatible
return is_non_null_type(
new_type
) and is_change_safe_for_object_or_interface_field(
cast(GraphQLNonNull, old_type).of_type,
cast(GraphQLNonNull, new_type).of_type,
)
return (
# if they're both named types, see if their names are equivalent
is_named_type(new_type)
and cast(GraphQLNamedType, old_type).name
== cast(GraphQLNamedType, new_type).name
) or (
# moving from nullable to non-null of same underlying type is safe
is_non_null_type(new_type)
and is_change_safe_for_object_or_interface_field(
old_type, cast(GraphQLNonNull, new_type).of_type
)
)
def is_change_safe_for_input_object_field_or_field_arg(
old_type: GraphQLType, new_type: GraphQLType
) -> bool:
if is_list_type(old_type):
return is_list_type(
# if they're both lists, make sure underlying types are compatible
new_type
) and is_change_safe_for_input_object_field_or_field_arg(
cast(GraphQLList, old_type).of_type, cast(GraphQLList, new_type).of_type
)
if is_non_null_type(old_type):
return (
# if they're both non-null, make sure the underlying types are compatible
is_non_null_type(new_type)
and is_change_safe_for_input_object_field_or_field_arg(
cast(GraphQLNonNull, old_type).of_type,
cast(GraphQLNonNull, new_type).of_type,
)
) or (
# moving from non-null to nullable of same underlying type is safe
not is_non_null_type(new_type)
and is_change_safe_for_input_object_field_or_field_arg(
cast(GraphQLNonNull, old_type).of_type, new_type
)
)
return (
# if they're both named types, see if their names are equivalent
is_named_type(new_type)
and cast(GraphQLNamedType, old_type).name
== cast(GraphQLNamedType, new_type).name
)
def type_kind_name(type_: GraphQLNamedType) -> str:
if is_scalar_type(type_):
return "a Scalar type"
if is_object_type(type_):
return "an Object type"
if is_interface_type(type_):
return "an Interface type"
if is_union_type(type_):
return "a Union type"
if is_enum_type(type_):
return "an Enum type"
if is_input_object_type(type_):
return "an Input type"
# Not reachable. All possible output types have been considered.
raise TypeError(f"Unexpected type {inspect(type)}")
def stringify_value(value: Any, type_: GraphQLInputType) -> str:
ast = ast_from_value(value, type_)
if ast is None: # pragma: no cover
raise TypeError(f"Invalid value: {inspect(value)}")
return print_ast(sort_value_node(ast))
class ListDiff(NamedTuple):
"""Tuple with added, removed and persisted list items."""
added: List
removed: List
persisted: List
def list_diff(old_list: Collection, new_list: Collection) -> ListDiff:
"""Get differences between two lists of named items."""
added = []
persisted = []
removed = []
old_set = {item.name for item in old_list}
new_map = {item.name: item for item in new_list}
for old_item in old_list:
new_item = new_map.get(old_item.name)
if new_item:
persisted.append([old_item, new_item])
else:
removed.append(old_item)
for new_item in new_list:
if new_item.name not in old_set:
added.append(new_item)
return ListDiff(added, removed, persisted)
class DictDiff(NamedTuple):
"""Tuple with added, removed and persisted dict entries."""
added: Dict
removed: Dict
persisted: Dict
def dict_diff(old_dict: Dict, new_dict: Dict) -> DictDiff:
"""Get differences between two dicts."""
added = {}
removed = {}
persisted = {}
for old_name, old_item in old_dict.items():
new_item = new_dict.get(old_name)
if new_item:
persisted[old_name] = [old_item, new_item]
else:
removed[old_name] = old_item
for new_name, new_item in new_dict.items():
if new_name not in old_dict:
added[new_name] = new_item
return DictDiff(added, removed, persisted)