Skip to content

Instantly share code, notes, and snippets.

@mikel-at-tatari
Last active March 12, 2024 16:42
Show Gist options
  • Save mikel-at-tatari/df7374654412efa3d0e1a28168e6744e to your computer and use it in GitHub Desktop.
Save mikel-at-tatari/df7374654412efa3d0e1a28168e6744e to your computer and use it in GitHub Desktop.
Backwards compatible fieldname deprecation in GraphQL
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