graphql-python / graphene

GraphQL framework for Python
http://graphene-python.org/
MIT License
8.07k stars 822 forks source link

Implement property-style Graphene fields #1301

Open AadamZ5 opened 3 years ago

AadamZ5 commented 3 years ago

Is your feature request related to a problem? Please describe. I am trying to learn how to use Graphene with my existing DataModels. The problem I've come across is my use of python's @property decorator. My DataModel uses these to ensure proper logic when new attributes are set, and only allowing certain ones to be used. With Graphene's implementation, I am expected to define my properties as attributes instead, which eliminates my ability to use logic when a property is set, or decide which properties can be set at all.

The documentation does explain that Graphene objects can be used as real data-retaining objects, but I am not willing to sacrifice my current inheritance and implementation for a simple ObjectType class implementation.

Additionally, the documentation also explains that any dictionary-like object can be used to lookup attributes with the same name, but now I am tasked with maintaining an additional class just to build the schema. I would prefer to keep my definition as tightly coupled to my actual implementation as I can, meaning I want to define it directly in my data-model.

Describe the solution you'd like I want a way to integrate Graphene's schema building with my model's properties. I would prefer not to write custom resolvers and specially name all of the attributes I want to expose. Perhaps a decorator approach could be used

@property
@graphene_property(type=String, name="custom_name", desc="This is a property-based graphene field")
def serial(self):
    ...

I imagine this decorator can be a subclass of Field from graphene.types.field. Maybe a name like @property_field better suits this. I am definitely willing to try and open a PR for this, but I wanted to have some initial discussion first. I want to explore this later using a descriptor.

Describe alternatives you've considered I am currently not using Django, but I've considered investing more into the Django stack only to be able to use the additional Meta class option of model. I have not done enough research, and adopting the entire Django stack into my program is not what I'd like to do.

Additional context I am currently not using Django. I would not like to implement it to fix this problem.

If you can think of a way that works around this issue without implementing a new feature, I'd be happy to discuss. I don't know the ins and outs of Graphene, and I want to express this all very humbly. Please let me know if I'm horribly overlooking something, or you believe my implementation is bonkers. Thanks for your time!

AadamZ5 commented 3 years ago

This is an equivalent (taken from one of the example classes) that I imagine what the use of some sort of field decorator may look like:

Original example

from graphene import ObjectType, String

class Person(ObjectType):
    first_name = String()
    last_name = String()
    full_name = String()

    def resolve_full_name(parent, info):
        return f"{parent.first_name} {parent.last_name}"

Decorated example

from graphene import ObjectType, String, field # <- `field` is a new class for decorating. 

class Person(ObjectType):
    first_name = String()
    last_name = String()

    @field(type=String)
    def full_name(self, info):
        """
        The docstring here can be used to implicitly gather the description
        """
        return f"{self.first_name} {self.last_name}"

Perhaps the decorator requires you use the native @property decorator above it first.

...
    @property
    @field(type=String)
    def full_name(self, info):
        """
        The docstring here can be used to implicitly gather the description
        """
        return f"{self.first_name} {self.last_name}"

It may be worth investigating if the decorator should be compatible with (possibly subclassed from) Python's @property decorator. Regardless, the implementation would might be mostly similar to this property remake example but as I mentioned in the original example, inherits from Field found in graphene.types.field. By inheriting from field, you can treat the field the same as one defined as normal, with just the __get__ method overloaded. However, the complexity arises by adding the decorator parameters as shown in my example. The implementation now differs, and will be detailed in a separate comment drafting an implementation.

AadamZ5 commented 3 years ago

My first-draft implementation

class FieldDecorator(Field):
    """
    FieldDecorator is a class used to make a property-style attribute a Graphene field.

    Warning! This class shouldn't be used as a decorator itself! 
    """
    def __init__(self, fget: Callable, fset: Callable, fdel: Callable, type_:UnmountedType, args=None, name:str=None, description:str=None, required:bool=None, default_value=None, deprecation_reason:str=None, **extra_args):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        if description is None and self.fget is not None:
            description = getdoc(self.fget)

        #We should assume that `fget` is our property getter, and thus is our resolver method. 
        super(FieldDecorator, self).__init__(type_, args=args, name=name, resolver=self.fget, deprecation_reason=deprecation_reason, description=description, required=required, default_value=default_value, **extra_args)

    #The get descriptor will call the function passed to us initially.
    def __get__(self, obj, objType=None):
        if obj is None:
            return self
        if self.fget is None:
            return AttributeError("Can't get attribute!")
        return self.fget(obj)

    #The set descriptor will call the function passed to us by using @<prop>.setter
    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("Can't set attribute!")
        self.fset(obj, value)

    #The delete descriptor will call the function passed to us by using @<prop>.deleter
    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError("Can't delete attribute!")
        self.fdel(obj)

    def getter(self, fget):
        required = isinstance(self.type, NonNull)
        return type(self)(fget, self.fset, self.fdel, self._type, self.args, self.name, self.description, required, self.default_value, self.deprecation_reason)

    def setter(self, fset):
        required = isinstance(self.type, NonNull)
        return type(self)(self.fget, fset, self.fdel, self._type, self.args, self.name, self.description, required, self.default_value, self.deprecation_reason)

    def deleter(self, fdel):
        required = isinstance(self.type, NonNull)
        return type(self)(self.fget, self.fset, fdel, self._type, self.args, self.name, self.description, required, self.default_value, self.deprecation_reason)

def field_property(func_=None, *a, type_=None, name=None, description=None, required=None, args=None, default_value=None, deprication_reason=None, **extra_args):
    """
    Denotes a property-style attribute as a Graphene field of type `type_`
    """

    #Check if type_ is supplied and valid
    if type_ is None or not isinstance(type_, UnmountedType):
        raise TypeError("type_ Must be of type UnmountedType!")

    # If `field` is called without any arguments whatsoever, then `func` is implicitly supplied in the call.
    # If it is called with keyword arguments, then `func` is no longer implicitly supplied.
    if func_ != None:
        return FieldDecorator(func_, None, None, type_, args=args, name=name, description=description, required=required, default_value=default_value, deprecation_reason=deprication_reason, **extra_args)
    else:
        def wrapper(func):
            return FieldDecorator(func, None, None, type_, args=args, name=name, description=description, required=required, default_value=default_value, deprecation_reason=deprication_reason, **extra_args)
        return wrapper

As I'm trying to make a first-draft implementation - just to try - I find myself stuck with questions of how the Schema builder works.

As long as the Schema builder uses raw types to build the schema, and not instances, the FieldDecorator will show itself with this logic.

...
    def __get__(self, obj, objType=None):
        if obj is None:
            return self
...

When inspecting a type (not an instance), there is no obj instance passed in as the implicit first argument, so the class returns itself for inspection.

I have all of these changes in a forked repo branch, and am ready to open a pull request with some basic tests implemented. I'd still like to discuss if this is a feasible and useful feature before moving ahead with a PR.

Thanks!