Last active
March 12, 2024 16:42
-
-
Save mikel-at-tatari/df7374654412efa3d0e1a28168e6744e to your computer and use it in GitHub Desktop.
Backwards compatible fieldname deprecation in GraphQL
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from __future__ import annotations | |
import dataclasses | |
from copy import deepcopy | |
from typing import Any | |
from graphene import ResolveInfo | |
from graphene.types.inputobjecttype import InputObjectTypeOptions | |
@dataclasses.dataclass | |
class DeprecatedField: | |
new_name: str | |
old_name: str | |
def is_a_deprecated_field(self, field_name): | |
return field_name in {self.new_name, self.old_name} | |
def is_old(self, field_name): | |
return field_name is self.old_name | |
class AbstractCustomInputContainerType(dict): | |
deprecated_fields: list[DeprecatedField] | |
def __init_subclass__( | |
cls, deprecated_fields: list[DeprecatedField], meta, **kwargs | |
): | |
cls.deprecated_fields = deprecated_fields | |
assert meta is not None | |
cls._meta = meta | |
for deprecated_field in deprecated_fields: | |
assert deprecated_field.new_name in meta.fields | |
assert deprecated_field.old_name in meta.fields | |
def __init__(self, *args, **kwargs): | |
dict.__init__(self, *args, **kwargs) | |
def _get_deprecated_field_or_none(self, key): | |
deprecated_field = [ | |
f for f in self.deprecated_fields if f.is_a_deprecated_field(key) | |
] | |
assert len(deprecated_field) <= 1 | |
return deprecated_field[0] if deprecated_field else None | |
def __getitem__(self, key): | |
deprecated_field = self._get_deprecated_field_or_none(key) | |
if not deprecated_field: | |
return super().__getitem__(key) | |
old_name = deprecated_field.old_name | |
new_name = deprecated_field.new_name | |
default_value = ( | |
self._meta.fields[new_name].default_value | |
if new_name in self._meta.fields | |
else None | |
) | |
if new_name not in self and old_name not in self and not default_value: | |
# If the requested key is not in the input container, and there is no default value for it, | |
# raise a KeyError with the requested key as an argument. | |
# if the key is missing, we need to check whether there is a default value specified for the new name of the key, | |
# and return that value if it exists. | |
raise KeyError(key) | |
if default_value is not None: | |
# check if the old key and new key are different from the default | |
if ( | |
new_name in self | |
and (val := super().__getitem__(new_name)) != default_value | |
): | |
return val | |
if ( | |
old_name in self | |
and (val := super().__getitem__(old_name)) != default_value | |
): | |
return val | |
return default_value | |
else: | |
try: | |
# if the new one is set, prefer that one | |
return super().__getitem__(new_name) | |
except KeyError: | |
return super().__getitem__(old_name) | |
def __getattr__(self, key): | |
try: | |
return self[key] | |
except KeyError: | |
deprecated_field = self._get_deprecated_field_or_none(key) | |
if ( | |
deprecated_field | |
and (f := self._meta.fields) | |
and (deprecated_field.old_name in f or deprecated_field.new_name in f) | |
): | |
return None | |
if self._meta.fields and key in self._meta.fields: | |
return None | |
raise AttributeError | |
def get(self, key: Any, default: Any = None): | |
try: | |
return self.__getattr__(key) | |
except AttributeError: | |
return default | |
@staticmethod | |
def build_new_container_type(class_name, deprecated_fields, meta): | |
return type( | |
class_name, | |
(AbstractCustomInputContainerType,), | |
{}, | |
deprecated_fields=deprecated_fields, | |
meta=meta, | |
) | |
def deprecate_gql(deprecate_fields: list[DeprecatedField]): | |
def cwrap(cls): | |
if type(cls._meta) == InputObjectTypeOptions: | |
new_meta = build_new_meta(cls._meta, cls.__name__, deprecate_fields) | |
cls._meta = new_meta | |
return cls | |
else: | |
# ObjectType | |
for f in deprecate_fields: | |
new_field = cls._meta.fields[f.new_name] | |
old_field = deepcopy(new_field) | |
# If the cls has a resolver fn (i.e. def resolve_newfieldname) for the new field name, then we will just | |
# artifically create another fn resolver in the cls using the old fieldname and point it to that | |
# new resolver fn (def resolve_oldfieldname). If it doesn't have a resolver, we have to rely on the | |
# underlying object to resolve the field | |
if hasattr(cls, f'resolve_{f.new_name}'): | |
setattr( | |
cls, | |
f'resolve_{f.old_name}', | |
getattr(cls, f'resolve_{f.new_name}'), | |
) | |
else: | |
# we artificially set the field's resolver value to a Callable that takes an object, ResolveInfo and | |
# **kwargs and try to resolve the value from the underlying object (as a dictionary lookup or | |
# attribute lookup) | |
def make_resolver(fieldname): | |
def resolver(obj, info: ResolveInfo, **kwargs): | |
if isinstance(obj, dict): | |
# dictionary resolver | |
return obj[fieldname] | |
# use attribute to resolve | |
return getattr(obj, fieldname) | |
return resolver | |
# artificially set the old field resolver to the new one we just created | |
old_field.resolver = make_resolver(f.new_name) | |
# we are artificially exposing the old field in the schema | |
cls._meta.fields[f.old_name] = old_field | |
return cls | |
return cwrap | |
def build_new_meta(old_meta, container_type_name, deprecated_fields): | |
new_meta = InputObjectTypeOptions(container_type_name) | |
new_meta.fields = deepcopy(old_meta.fields) | |
new_meta.name = old_meta.name | |
for f in deprecated_fields: | |
# we are artificially exposing the old field in the schema | |
new_meta.fields[f.old_name] = new_meta.fields[f.new_name] | |
new_meta.container = AbstractCustomInputContainerType.build_new_container_type( | |
container_type_name, | |
deprecated_fields=deprecated_fields, | |
meta=new_meta, | |
) | |
return new_meta |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment